diff --git a/.idea/misc.xml b/.idea/misc.xml index b9eee8c01..86adf2483 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -30,7 +30,7 @@ - + diff --git a/agent/build.gradle.kts b/agent/build.gradle.kts index 300483c52..5ac117c7f 100644 --- a/agent/build.gradle.kts +++ b/agent/build.gradle.kts @@ -1,4 +1,5 @@ plugins { + com.teamscale.`kotlin-convention` com.teamscale.`java-convention` application diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/Agent.java b/agent/src/main/java/com/teamscale/jacoco/agent/Agent.java deleted file mode 100644 index 9f82fe9e1..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/Agent.java +++ /dev/null @@ -1,201 +0,0 @@ -/*-------------------------------------------------------------------------+ -| | -| Copyright (c) 2009-2018 CQSE GmbH | -| | -+-------------------------------------------------------------------------*/ -package com.teamscale.jacoco.agent; - -import com.teamscale.client.FileSystemUtils; -import com.teamscale.client.StringUtils; -import com.teamscale.jacoco.agent.options.AgentOptions; -import com.teamscale.jacoco.agent.upload.IUploadRetry; -import com.teamscale.jacoco.agent.upload.IUploader; -import com.teamscale.jacoco.agent.upload.UploaderException; -import com.teamscale.jacoco.agent.util.AgentUtils; -import com.teamscale.jacoco.agent.util.Benchmark; -import com.teamscale.jacoco.agent.util.Timer; -import com.teamscale.report.jacoco.CoverageFile; -import com.teamscale.report.jacoco.EmptyReportException; -import com.teamscale.report.jacoco.JaCoCoXmlReportGenerator; -import com.teamscale.report.jacoco.dump.Dump; -import org.glassfish.jersey.server.ResourceConfig; -import org.glassfish.jersey.server.ServerProperties; - -import java.io.File; -import java.io.IOException; -import java.lang.instrument.Instrumentation; -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.Duration; -import java.util.List; -import java.util.Properties; -import java.util.stream.Stream; - -import static com.teamscale.jacoco.agent.logging.LoggingUtils.wrap; -import static com.teamscale.jacoco.agent.upload.teamscale.TeamscaleUploader.RETRY_UPLOAD_FILE_SUFFIX; - -/** - * A wrapper around the JaCoCo Java agent that automatically triggers a dump and XML conversion based on a time - * interval. - */ -public class Agent extends AgentBase { - - /** Converts binary data to XML. */ - private final JaCoCoXmlReportGenerator generator; - - /** Regular dump task. */ - private Timer timer; - - /** Stores the XML files. */ - protected final IUploader uploader; - - /** Constructor. */ - public Agent(AgentOptions options, Instrumentation instrumentation) - throws IllegalStateException, UploaderException { - super(options); - - uploader = options.createUploader(instrumentation); - logger.info("Upload method: {}", uploader.describe()); - retryUnsuccessfulUploads(options, uploader); - generator = new JaCoCoXmlReportGenerator(options.getClassDirectoriesOrZips(), - options.getLocationIncludeFilter(), options.getDuplicateClassFileBehavior(), - options.shouldIgnoreUncoveredClasses(), wrap(logger)); - - if (options.shouldDumpInIntervals()) { - timer = new Timer(this::dumpReport, Duration.ofMinutes(options.getDumpIntervalInMinutes())); - timer.start(); - logger.info("Dumping every {} minutes.", options.getDumpIntervalInMinutes()); - } - if (options.getTeamscaleServerOptions().partition != null) { - controller.setSessionId(options.getTeamscaleServerOptions().partition); - } - } - - /** - * If we have coverage that was leftover because of previously unsuccessful coverage uploads, we retry to upload - * them again with the same configuration as in the previous try. - */ - private void retryUnsuccessfulUploads(AgentOptions options, IUploader uploader) { - Path outputPath = options.getOutputDirectory(); - if (outputPath == null) { - // Default fallback - outputPath = AgentUtils.getAgentDirectory().resolve("coverage"); - } - - Path parentPath = outputPath.getParent(); - if (parentPath == null) { - logger.error("The output path '{}' does not have a parent path. Canceling upload retry.", - outputPath.toAbsolutePath()); - return; - } - - List reuploadCandidates = FileSystemUtils.listFilesRecursively(parentPath.toFile(), - filepath -> filepath.getName().endsWith(RETRY_UPLOAD_FILE_SUFFIX)); - for (File file : reuploadCandidates) { - reuploadCoverageFromPropertiesFile(file, uploader); - } - } - - private void reuploadCoverageFromPropertiesFile(File file, IUploader uploader) { - logger.info("Retrying previously unsuccessful coverage upload for file {}.", file); - try { - Properties properties = FileSystemUtils.readProperties(file); - CoverageFile coverageFile = new CoverageFile( - new File(StringUtils.stripSuffix(file.getAbsolutePath(), RETRY_UPLOAD_FILE_SUFFIX))); - - if (uploader instanceof IUploadRetry) { - ((IUploadRetry) uploader).reupload(coverageFile, properties); - } else { - logger.info("Reupload not implemented for uploader {}", uploader.describe()); - } - Files.deleteIfExists(file.toPath()); - } catch (IOException e) { - logger.error("Reuploading coverage failed. " + e); - } - } - - @Override - protected ResourceConfig initResourceConfig() { - ResourceConfig resourceConfig = new ResourceConfig(); - resourceConfig.property(ServerProperties.WADL_FEATURE_DISABLE, Boolean.TRUE.toString()); - AgentResource.setAgent(this); - return resourceConfig.register(AgentResource.class).register(GenericExceptionMapper.class); - } - - @Override - protected void prepareShutdown() { - if (timer != null) { - timer.stop(); - } - if (options.shouldDumpOnExit()) { - dumpReport(); - } - - try { - deleteDirectoryIfEmpty(options.getOutputDirectory()); - } catch (IOException e) { - logger.info( - "Could not delete empty output directory {}. " - + "This directory was created inside the configured output directory to be able to " - + "distinguish between different runs of the profiled JVM. You may delete it manually.", - options.getOutputDirectory(), e); - } - } - - /** - * Delete a directory from disk if it is empty. This method does nothing if the path provided does not exist or - * point to a file. - * - * @throws IOException if the deletion of the directory fails - */ - private static void deleteDirectoryIfEmpty(Path directory) throws IOException { - if (!Files.isDirectory(directory)) { - return; - } - - try (Stream stream = Files.list(directory)) { - if (stream.findFirst().isPresent()) { - return; - } - } - - Files.delete(directory); - } - - /** - * Dumps the current execution data, converts it, writes it to the output directory defined in {@link #options} and - * uploads it if an uploader is configured. Logs any errors, never throws an exception. - */ - @Override - public void dumpReport() { - logger.debug("Starting dump"); - - try { - dumpReportUnsafe(); - } catch (Throwable t) { - // we want to catch anything in order to avoid crashing the whole system under - // test - logger.error("Dump job failed with an exception", t); - } - } - - private void dumpReportUnsafe() { - Dump dump; - try { - dump = controller.dumpAndReset(); - } catch (JacocoRuntimeController.DumpException e) { - logger.error("Dumping failed, retrying later", e); - return; - } - - try (Benchmark ignored = new Benchmark("Generating the XML report")) { - File outputFile = options.createNewFileInOutputDirectory("jacoco", "xml"); - CoverageFile coverageFile = generator.convertSingleDumpToReport(dump, outputFile); - uploader.upload(coverageFile); - } catch (IOException e) { - logger.error("Converting binary dump to XML failed", e); - } catch (EmptyReportException e) { - logger.error("No coverage was collected. " + e.getMessage(), e); - } - } -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/AgentBase.java b/agent/src/main/java/com/teamscale/jacoco/agent/AgentBase.java deleted file mode 100644 index 13b98f37e..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/AgentBase.java +++ /dev/null @@ -1,157 +0,0 @@ -package com.teamscale.jacoco.agent; - -import com.teamscale.jacoco.agent.logging.LoggingUtils; -import com.teamscale.jacoco.agent.options.AgentOptions; -import org.eclipse.jetty.server.Server; -import org.eclipse.jetty.server.ServerConnector; -import org.eclipse.jetty.servlet.ServletContextHandler; -import org.eclipse.jetty.servlet.ServletHolder; -import org.eclipse.jetty.util.thread.QueuedThreadPool; -import org.glassfish.jersey.server.ResourceConfig; -import org.glassfish.jersey.servlet.ServletContainer; -import org.jacoco.agent.rt.RT; -import org.slf4j.Logger; - -import java.lang.management.ManagementFactory; - -/** - * Base class for agent implementations. Handles logger shutdown, store creation and instantiation of the - * {@link JacocoRuntimeController}. - *

- * Subclasses must handle dumping onto disk and uploading via the configured uploader. - */ -public abstract class AgentBase { - - /** The logger. */ - protected final Logger logger = LoggingUtils.getLogger(this); - - /** Controls the JaCoCo runtime. */ - public final JacocoRuntimeController controller; - - /** The agent options. */ - protected AgentOptions options; - - private Server server; - - /** Constructor. */ - public AgentBase(AgentOptions options) throws IllegalStateException { - this.options = options; - - try { - controller = new JacocoRuntimeController(RT.getAgent()); - } catch (IllegalStateException e) { - throw new IllegalStateException( - "Teamscale Java Profiler not started or there is a conflict with another agent on the classpath.", - e); - } - logger.info("Starting Teamscale Java Profiler for process {} with options: {}", - ManagementFactory.getRuntimeMXBean().getName(), getOptionsObjectToLog()); - if (options.getHttpServerPort() != null) { - try { - initServer(); - } catch (Exception e) { - logger.error("Could not start http server on port " + options.getHttpServerPort() - + ". Please check if the port is blocked."); - throw new IllegalStateException("Control server not started.", e); - } - } - } - - - - /** - * Lazily generated string representation of the command line arguments to print to the log. - */ - private Object getOptionsObjectToLog() { - return new Object() { - @Override - public String toString() { - if (options.shouldObfuscateSecurityRelatedOutputs()) { - return options.getObfuscatedOptionsString(); - } - return options.getOriginalOptionsString(); - } - }; - } - - /** - * Starts the http server, which waits for information about started and finished tests. - */ - private void initServer() throws Exception { - logger.info("Listening for test events on port {}.", options.getHttpServerPort()); - - // Jersey Implementation - ServletContextHandler handler = buildUsingResourceConfig(); - QueuedThreadPool threadPool = new QueuedThreadPool(); - threadPool.setMaxThreads(10); - threadPool.setDaemon(true); - - // Create a server instance and set the thread pool - server = new Server(threadPool); - // Create a server connector, set the port and add it to the server - ServerConnector connector = new ServerConnector(server); - connector.setPort(options.getHttpServerPort()); - server.addConnector(connector); - server.setHandler(handler); - server.start(); - } - - private ServletContextHandler buildUsingResourceConfig() { - ServletContextHandler handler = new ServletContextHandler(ServletContextHandler.NO_SESSIONS); - handler.setContextPath("/"); - - ResourceConfig resourceConfig = initResourceConfig(); - handler.addServlet(new ServletHolder(new ServletContainer(resourceConfig)), "/*"); - return handler; - } - - /** - * Initializes the {@link ResourceConfig} needed for the Jetty + Jersey Server - */ - protected abstract ResourceConfig initResourceConfig(); - - /** - * Registers a shutdown hook that stops the timer and dumps coverage a final time. - */ - void registerShutdownHook() { - Runtime.getRuntime().addShutdownHook(new Thread(() -> { - try { - logger.info("Teamscale Java Profiler is shutting down..."); - stopServer(); - prepareShutdown(); - logger.info("Teamscale Java Profiler successfully shut down."); - } catch (Exception e) { - logger.error("Exception during profiler shutdown.", e); - } finally { - // Try to flush logging resources also in case of an exception during shutdown - PreMain.closeLoggingResources(); - } - })); - } - - /** Stop the http server if it's running */ - void stopServer() { - if (options.getHttpServerPort() != null) { - try { - server.stop(); - } catch (Exception e) { - logger.error("Could not stop server so it is killed now.", e); - } finally { - server.destroy(); - } - } - } - - /** Called when the shutdown hook is triggered. */ - protected void prepareShutdown() { - // Template method to be overridden by subclasses. - } - - /** - * Dumps the current execution data, converts it, writes it to the output - * directory defined in {@link #options} and uploads it if an uploader is - * configured. Logs any errors, never throws an exception. - */ - public abstract void dumpReport(); - -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/AgentResource.java b/agent/src/main/java/com/teamscale/jacoco/agent/AgentResource.java deleted file mode 100644 index 51684e739..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/AgentResource.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.teamscale.jacoco.agent; - -import javax.ws.rs.POST; -import javax.ws.rs.Path; -import javax.ws.rs.core.Response; - -/** - * The resource of the Jersey + Jetty http server holding all the endpoints specific for the {@link Agent}. - */ -@Path("/") -public class AgentResource extends ResourceBase { - - private static Agent agent; - - /** - * Static setter to inject the {@link Agent} to the resource. - */ - public static void setAgent(Agent agent) { - AgentResource.agent = agent; - ResourceBase.agentBase = agent; - } - - /** Handles dumping a XML coverage report for coverage collected until now. */ - @POST - @Path("/dump") - public Response handleDump() { - logger.debug("Dumping report triggered via HTTP request"); - agent.dumpReport(); - return Response.noContent().build(); - } - - /** Handles resetting of coverage. */ - @POST - @Path("/reset") - public Response handleReset() { - logger.debug("Resetting coverage triggered via HTTP request"); - agent.controller.reset(); - return Response.noContent().build(); - } - -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/DelayedLogger.java b/agent/src/main/java/com/teamscale/jacoco/agent/DelayedLogger.java deleted file mode 100644 index 574389c58..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/DelayedLogger.java +++ /dev/null @@ -1,70 +0,0 @@ -package com.teamscale.jacoco.agent; - -import com.teamscale.report.util.ILogger; -import org.slf4j.Logger; - -import java.util.ArrayList; -import java.util.List; - -/** - * A logger that buffers logs in memory and writes them to the actual logger at a later point. This is needed when stuff - * needs to be logged before the actual logging framework is initialized. - */ -public class DelayedLogger implements ILogger { - - /** List of log actions that will be executed once the logger is initialized. */ - private final List logActions = new ArrayList<>(); - - @Override - public void debug(String message) { - logActions.add(logger -> logger.debug(message)); - } - - @Override - public void info(String message) { - logActions.add(logger -> logger.info(message)); - } - - @Override - public void warn(String message) { - logActions.add(logger -> logger.warn(message)); - } - - @Override - public void warn(String message, Throwable throwable) { - logActions.add(logger -> logger.warn(message, throwable)); - } - - @Override - public void error(Throwable throwable) { - logActions.add(logger -> logger.error(throwable.getMessage(), throwable)); - } - - @Override - public void error(String message, Throwable throwable) { - logActions.add(logger -> logger.error(message, throwable)); - } - - /** - * Logs an error and also writes the message to {@link System#err} to ensure the message is even logged in case - * setting up the logger itself fails for some reason (see TS-23151). - */ - public void errorAndStdErr(String message, Throwable throwable) { - System.err.println(message); - logActions.add(logger -> logger.error(message, throwable)); - } - - /** Writes the logs to the given slf4j logger. */ - public void logTo(Logger logger) { - logActions.forEach(action -> action.log(logger)); - } - - /** An action to be executed on a logger. */ - private interface ILoggerAction { - - /** Executes the action on the given logger. */ - void log(Logger logger); - - } -} - diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/GenericExceptionMapper.java b/agent/src/main/java/com/teamscale/jacoco/agent/GenericExceptionMapper.java deleted file mode 100644 index 76c1d043f..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/GenericExceptionMapper.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.teamscale.jacoco.agent; - -import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.Response; -import javax.ws.rs.ext.ExceptionMapper; - -/** - * Generates a {@link Response} for an exception. - */ -@javax.ws.rs.ext.Provider -public class GenericExceptionMapper implements ExceptionMapper { - - @Override - public Response toResponse(Throwable e) { - Response.ResponseBuilder errorResponse = Response.status(Response.Status.INTERNAL_SERVER_ERROR); - errorResponse.type(MediaType.TEXT_PLAIN_TYPE); - errorResponse.entity("Message: " + e.getMessage()); - return errorResponse.build(); - } -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/JacocoRuntimeController.java b/agent/src/main/java/com/teamscale/jacoco/agent/JacocoRuntimeController.java deleted file mode 100644 index 1ad9b2146..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/JacocoRuntimeController.java +++ /dev/null @@ -1,139 +0,0 @@ -/*-------------------------------------------------------------------------+ -| | -| Copyright (c) 2009-2018 CQSE GmbH | -| | -+-------------------------------------------------------------------------*/ -package com.teamscale.jacoco.agent; - -import com.teamscale.report.jacoco.dump.Dump; -import org.jacoco.agent.rt.IAgent; -import org.jacoco.agent.rt.RT; -import org.jacoco.core.data.ExecutionDataReader; -import org.jacoco.core.data.ExecutionDataStore; -import org.jacoco.core.data.ISessionInfoVisitor; -import org.jacoco.core.data.SessionInfo; - -import java.io.ByteArrayInputStream; -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; - -/** - * Wrapper around JaCoCo's {@link RT} runtime interface. - *

- * Can be used if the calling code is run in the same JVM as the agent is attached to. - */ -public class JacocoRuntimeController { - - /** Indicates a failed dump. */ - public static class DumpException extends Exception { - - /** Serialization ID. */ - private static final long serialVersionUID = 1L; - - /** Constructor. */ - public DumpException(String message, Throwable cause) { - super(message, cause); - } - - } - - /** JaCoCo's {@link RT} agent instance */ - private final IAgent agent; - - /** Constructor. */ - public JacocoRuntimeController(IAgent agent) { - this.agent = agent; - } - - /** - * Dumps execution data and resets it. - * - * @throws DumpException if dumping fails. This should never happen in real life. Dumping should simply be retried - * later if this ever happens. - */ - public Dump dumpAndReset() throws DumpException { - byte[] binaryData = agent.getExecutionData(true); - - try (ByteArrayInputStream inputStream = new ByteArrayInputStream(binaryData)) { - ExecutionDataReader reader = new ExecutionDataReader(inputStream); - - ExecutionDataStore store = new ExecutionDataStore(); - reader.setExecutionDataVisitor(store::put); - - SessionInfoVisitor sessionInfoVisitor = new SessionInfoVisitor(); - reader.setSessionInfoVisitor(sessionInfoVisitor); - - reader.read(); - return new Dump(sessionInfoVisitor.sessionInfo, store); - } catch (IOException e) { - throw new DumpException("should never happen for the ByteArrayInputStream", e); - } - } - - /** - * Dumps execution data to the given file and resets it afterwards. - */ - public void dumpToFileAndReset(File file) throws IOException { - byte[] binaryData = agent.getExecutionData(true); - - try (FileOutputStream outputStream = new FileOutputStream(file, true)) { - outputStream.write(binaryData); - } - } - - - /** - * Dumps execution data to a file and resets it. - * - * @throws DumpException if dumping fails. This should never happen in real life. Dumping should simply be retried - * later if this ever happens. - */ - public void dump() throws DumpException { - try { - agent.dump(true); - } catch (IOException e) { - throw new DumpException(e.getMessage(), e); - } - } - - /** Resets already collected coverage. */ - public void reset() { - agent.reset(); - } - - /** Returns the current sessionId. */ - public String getSessionId() { - return agent.getSessionId(); - } - - /** - * Sets the current sessionId of the agent that can be used to identify which coverage is recorded from now on. - */ - public void setSessionId(String sessionId) { - agent.setSessionId(sessionId); - } - - /** Unsets the session ID so that coverage collected from now on is not attributed to the previous test. */ - public void resetSessionId() { - agent.setSessionId(""); - } - - /** - * Receives and stores a {@link SessionInfo}. Has a fallback dummy session in case nothing is received. - */ - private static class SessionInfoVisitor implements ISessionInfoVisitor { - - /** The received session info or a dummy. */ - public SessionInfo sessionInfo = new SessionInfo("dummysession", System.currentTimeMillis(), - System.currentTimeMillis()); - - /** {@inheritDoc} */ - @Override - public void visitSessionInfo(SessionInfo info) { - this.sessionInfo = info; - } - - } - -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/LenientCoverageTransformer.java b/agent/src/main/java/com/teamscale/jacoco/agent/LenientCoverageTransformer.java deleted file mode 100644 index 51c65737b..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/LenientCoverageTransformer.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.teamscale.jacoco.agent; - -import org.jacoco.agent.rt.internal_29a6edd.CoverageTransformer; -import org.jacoco.agent.rt.internal_29a6edd.core.runtime.AgentOptions; -import org.jacoco.agent.rt.internal_29a6edd.core.runtime.IRuntime; -import org.slf4j.Logger; - -import java.lang.instrument.IllegalClassFormatException; -import java.security.ProtectionDomain; - -/** - * A class file transformer which delegates to the JaCoCo {@link CoverageTransformer} to do the actual instrumentation, - * but treats instrumentation errors e.g. due to unsupported class file versions more lenient by only logging them, but - * not bailing out completely. Those unsupported classes will not be instrumented and will therefore not be contained in - * the collected coverage report. - */ -public class LenientCoverageTransformer extends CoverageTransformer { - - private final Logger logger; - - public LenientCoverageTransformer(IRuntime runtime, AgentOptions options, Logger logger) { - // The coverage transformer only uses the logger to print an error when the instrumentation fails. - // We want to show our more specific error message instead, so we only log this for debugging at trace. - super(runtime, options, e -> logger.trace(e.getMessage(), e)); - this.logger = logger; - } - - @Override - public byte[] transform(ClassLoader loader, String classname, Class classBeingRedefined, - ProtectionDomain protectionDomain, - byte[] classfileBuffer) { - try { - return super.transform(loader, classname, classBeingRedefined, protectionDomain, classfileBuffer); - } catch (IllegalClassFormatException e) { - logger.error( - "Failed to instrument " + classname + ". File will be skipped from instrumentation. " + - "No coverage will be collected for it. Exclude the file from the instrumentation or try " + - "updating the Teamscale Java Profiler if the file should actually be instrumented. (Cause: {})", - getRootCauseMessage(e)); - return null; - } - } - - private static String getRootCauseMessage(Throwable e) { - if (e.getCause() != null) { - return getRootCauseMessage(e.getCause()); - } - return e.getMessage(); - } -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/Main.java b/agent/src/main/java/com/teamscale/jacoco/agent/Main.java deleted file mode 100644 index 20c92dae8..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/Main.java +++ /dev/null @@ -1,84 +0,0 @@ -package com.teamscale.jacoco.agent; - -import com.beust.jcommander.JCommander; -import com.beust.jcommander.JCommander.Builder; -import com.beust.jcommander.Parameter; -import com.beust.jcommander.ParameterException; -import com.teamscale.client.StringUtils; -import com.teamscale.jacoco.agent.commandline.Validator; -import com.teamscale.jacoco.agent.convert.ConvertCommand; -import com.teamscale.jacoco.agent.logging.LoggingUtils; -import com.teamscale.jacoco.agent.util.AgentUtils; -import org.jacoco.core.JaCoCo; -import org.slf4j.Logger; - -/** Provides a command line interface for interacting with JaCoCo. */ -public class Main { - - /** The logger. */ - private final Logger logger = LoggingUtils.getLogger(this); - - /** The default arguments that will always be parsed. */ - private final DefaultArguments defaultArguments = new DefaultArguments(); - - /** The arguments for the one-time conversion process. */ - private final ConvertCommand command = new ConvertCommand(); - - /** Entry point. */ - public static void main(String[] args) throws Exception { - new Main().parseCommandLineAndRun(args); - } - - /** - * Parses the given command line arguments. Exits the program or throws an exception if the arguments are not valid. - * Then runs the specified command. - */ - private void parseCommandLineAndRun(String[] args) throws Exception { - Builder builder = createJCommanderBuilder(); - JCommander jCommander = builder.build(); - - try { - jCommander.parse(args); - } catch (ParameterException e) { - handleInvalidCommandLine(jCommander, e.getMessage()); - } - - if (defaultArguments.help) { - System.out.println( - "Teamscale Java Profiler " + AgentUtils.VERSION + " compiled against JaCoCo " + JaCoCo.VERSION); - jCommander.usage(); - return; - } - - Validator validator = command.validate(); - if (!validator.isValid()) { - handleInvalidCommandLine(jCommander, StringUtils.LINE_FEED + validator.getErrorMessage()); - } - - logger.info( - "Starting Teamscale Java Profiler " + AgentUtils.VERSION + " compiled against JaCoCo " + JaCoCo.VERSION); - command.run(); - } - - /** Creates a builder for a {@link JCommander} object. */ - private Builder createJCommanderBuilder() { - return JCommander.newBuilder().programName(Main.class.getName()).addObject(defaultArguments).addObject(command); - } - - /** Shows an informative error and help message. Then exits the program. */ - private static void handleInvalidCommandLine(JCommander jCommander, String message) { - System.err.println("Invalid command line: " + message + StringUtils.LINE_FEED); - jCommander.usage(); - System.exit(1); - } - - /** Default arguments that may always be provided. */ - private static class DefaultArguments { - - /** Shows the help message. */ - @Parameter(names = "--help", help = true, description = "Shows all available command line arguments.") - private boolean help; - - } - -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/PreMain.java b/agent/src/main/java/com/teamscale/jacoco/agent/PreMain.java deleted file mode 100644 index 921a115b8..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/PreMain.java +++ /dev/null @@ -1,305 +0,0 @@ -package com.teamscale.jacoco.agent; - -import com.teamscale.client.FileSystemUtils; -import com.teamscale.client.HttpUtils; -import com.teamscale.client.StringUtils; -import com.teamscale.jacoco.agent.configuration.AgentOptionReceiveException; -import com.teamscale.jacoco.agent.logging.DebugLogDirectoryPropertyDefiner; -import com.teamscale.jacoco.agent.logging.LogDirectoryPropertyDefiner; -import com.teamscale.jacoco.agent.logging.LogToTeamscaleAppender; -import com.teamscale.jacoco.agent.logging.LoggingUtils; -import com.teamscale.jacoco.agent.options.AgentOptionParseException; -import com.teamscale.jacoco.agent.options.AgentOptions; -import com.teamscale.jacoco.agent.options.AgentOptionsParser; -import com.teamscale.jacoco.agent.options.FilePatternResolver; -import com.teamscale.jacoco.agent.options.JacocoAgentOptionsBuilder; -import com.teamscale.jacoco.agent.options.TeamscaleCredentials; -import com.teamscale.jacoco.agent.options.TeamscalePropertiesUtils; -import com.teamscale.jacoco.agent.testimpact.TestwiseCoverageAgent; -import com.teamscale.jacoco.agent.upload.UploaderException; -import com.teamscale.jacoco.agent.util.AgentUtils; -import com.teamscale.report.util.ILogger; -import kotlin.Pair; -import org.slf4j.Logger; - -import java.io.File; -import java.io.IOException; -import java.lang.instrument.Instrumentation; -import java.lang.management.ManagementFactory; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.List; -import java.util.Optional; -import java.util.stream.Collectors; - -import static com.teamscale.jacoco.agent.logging.LoggingUtils.getLoggerContext; - -/** Container class for the premain entry point for the agent. */ -public class PreMain { - - private static LoggingUtils.LoggingResources loggingResources = null; - - /** - * System property that we use to prevent this agent from being attached to the same VM twice. This can happen if - * the agent is registered via multiple JVM environment variables and/or the command line at the same time. - */ - private static final String LOCKING_SYSTEM_PROPERTY = "TEAMSCALE_JAVA_PROFILER_ATTACHED"; - - /** - * Environment variable from which to read the config ID to use. This is an ID for a profiler configuration that is - * stored in Teamscale. - */ - private static final String CONFIG_ID_ENVIRONMENT_VARIABLE = "TEAMSCALE_JAVA_PROFILER_CONFIG_ID"; - - /** Environment variable from which to read the config file to use. */ - private static final String CONFIG_FILE_ENVIRONMENT_VARIABLE = "TEAMSCALE_JAVA_PROFILER_CONFIG_FILE"; - - /** Environment variable from which to read the Teamscale access token. */ - private static final String ACCESS_TOKEN_ENVIRONMENT_VARIABLE = "TEAMSCALE_ACCESS_TOKEN"; - - /** - * Entry point for the agent, called by the JVM. - */ - public static void premain(String options, Instrumentation instrumentation) throws Exception { - if (System.getProperty(LOCKING_SYSTEM_PROPERTY) != null) { - return; - } - System.setProperty(LOCKING_SYSTEM_PROPERTY, "true"); - - String environmentConfigId = System.getenv(CONFIG_ID_ENVIRONMENT_VARIABLE); - String environmentConfigFile = System.getenv(CONFIG_FILE_ENVIRONMENT_VARIABLE); - if (StringUtils.isEmpty(options) && environmentConfigId == null && environmentConfigFile == null) { - // profiler was registered globally and no config was set explicitly by the user, thus ignore this process - // and don't profile anything - return; - } - - AgentOptions agentOptions = null; - try { - Pair> parseResult = getAndApplyAgentOptions(options, environmentConfigId, - environmentConfigFile); - agentOptions = parseResult.getFirst(); - - // After parsing everything and configuring logging, we now - // can throw the caught exceptions. - for (Exception exception : parseResult.getSecond()) { - throw exception; - } - } catch (AgentOptionParseException e) { - getLoggerContext().getLogger(PreMain.class).error(e.getMessage(), e); - - // Flush logs to Teamscale, if configured. - closeLoggingResources(); - - // Unregister the profiler from Teamscale. - if (agentOptions != null && agentOptions.configurationViaTeamscale != null) { - agentOptions.configurationViaTeamscale.unregisterProfiler(); - } - - throw e; - } catch (AgentOptionReceiveException e) { - // When Teamscale is not available, we don't want to fail hard to still allow for testing even if no - // coverage is collected (see TS-33237) - return; - } - - Logger logger = LoggingUtils.getLogger(Agent.class); - - logger.info("Teamscale Java profiler version " + AgentUtils.VERSION); - logger.info("Starting JaCoCo's agent"); - JacocoAgentOptionsBuilder agentBuilder = new JacocoAgentOptionsBuilder(agentOptions); - JaCoCoPreMain.premain(agentBuilder.createJacocoAgentOptions(), instrumentation, logger); - - if (agentOptions.configurationViaTeamscale != null) { - agentOptions.configurationViaTeamscale.startHeartbeatThreadAndRegisterShutdownHook(); - } - AgentBase agent = createAgent(agentOptions, instrumentation); - agent.registerShutdownHook(); - } - - private static Pair> getAndApplyAgentOptions(String options, - String environmentConfigId, - String environmentConfigFile) throws AgentOptionParseException, IOException, AgentOptionReceiveException { - - DelayedLogger delayedLogger = new DelayedLogger(); - List javaAgents = ManagementFactory.getRuntimeMXBean().getInputArguments().stream().filter( - s -> s.contains("-javaagent")).collect(Collectors.toList()); - // We allow multiple instances of the teamscale-jacoco-agent as we ensure with the #LOCKING_SYSTEM_PROPERTY to only use it once - List differentAgents = javaAgents.stream() - .filter(javaAgent -> !javaAgent.contains("teamscale-jacoco-agent.jar")).collect( - Collectors.toList()); - - if (!differentAgents.isEmpty()) { - delayedLogger.warn( - "Using multiple java agents could interfere with coverage recording: " + - String.join(", ", differentAgents)); - } - if (!javaAgents.get(0).contains("teamscale-jacoco-agent.jar")) { - delayedLogger.warn("For best results consider registering the Teamscale Java Profiler first."); - } - - TeamscaleCredentials credentials = TeamscalePropertiesUtils.parseCredentials(); - if (credentials == null) { - // As many users still don't use the installer based setup, this log message will be shown in almost every log. - // We use a debug log, as this message can be confusing for customers that think a teamscale.properties file is synonymous with a config file. - delayedLogger.debug( - "No explicit teamscale.properties file given. Looking for Teamscale credentials in a config file or via a command line argument. This is expected unless the installer based setup was used."); - } - - String environmentAccessToken = System.getenv(ACCESS_TOKEN_ENVIRONMENT_VARIABLE); - - Pair> parseResult; - AgentOptions agentOptions; - try { - parseResult = AgentOptionsParser.parse( - options, environmentConfigId, environmentConfigFile, credentials, environmentAccessToken, - delayedLogger); - agentOptions = parseResult.getFirst(); - } catch (AgentOptionParseException e) { - try (LoggingUtils.LoggingResources ignored = initializeFallbackLogging(options, delayedLogger)) { - delayedLogger.errorAndStdErr("Failed to parse agent options: " + e.getMessage(), e); - attemptLogAndThrow(delayedLogger); - throw e; - } - } catch (AgentOptionReceiveException e) { - try (LoggingUtils.LoggingResources ignored = initializeFallbackLogging(options, delayedLogger)) { - delayedLogger.errorAndStdErr( - e.getMessage() + " The application should start up normally, but NO coverage will be collected! Check the log file for details.", - e); - attemptLogAndThrow(delayedLogger); - throw e; - } - } - - initializeLogging(agentOptions, delayedLogger); - Logger logger = LoggingUtils.getLogger(Agent.class); - delayedLogger.logTo(logger); - HttpUtils.setShouldValidateSsl(agentOptions.shouldValidateSsl()); - - return parseResult; - } - - private static void attemptLogAndThrow(DelayedLogger delayedLogger) { - // We perform actual logging output after writing to console to - // ensure the console is reached even in case of logging issues - // (see TS-23151). We use the Agent class here (same as below) - Logger logger = LoggingUtils.getLogger(Agent.class); - delayedLogger.logTo(logger); - } - - /** Initializes logging during {@link #premain(String, Instrumentation)} and also logs the log directory. */ - private static void initializeLogging(AgentOptions agentOptions, DelayedLogger logger) throws IOException { - if (agentOptions.isDebugLogging()) { - initializeDebugLogging(agentOptions, logger); - } else { - loggingResources = LoggingUtils.initializeLogging(agentOptions.getLoggingConfig()); - logger.info("Logging to " + new LogDirectoryPropertyDefiner().getPropertyValue()); - } - - if (agentOptions.getTeamscaleServerOptions().isConfiguredForServerConnection()) { - if (LogToTeamscaleAppender.addTeamscaleAppenderTo(getLoggerContext(), agentOptions)) { - logger.info("Logs are being forwarded to Teamscale at " + agentOptions.getTeamscaleServerOptions().url); - } - } - } - - /** Closes the opened logging contexts. */ - static void closeLoggingResources() { - loggingResources.close(); - } - - /** - * Returns in instance of the agent that was configured. Either an agent with interval based line-coverage dump or - * the HTTP server is used. - */ - private static AgentBase createAgent(AgentOptions agentOptions, - Instrumentation instrumentation) throws UploaderException, IOException { - if (agentOptions.useTestwiseCoverageMode()) { - return TestwiseCoverageAgent.create(agentOptions); - } else { - return new Agent(agentOptions, instrumentation); - } - } - - /** - * Initializes debug logging during {@link #premain(String, Instrumentation)} and also logs the log directory if - * given. - */ - private static void initializeDebugLogging(AgentOptions agentOptions, DelayedLogger logger) { - loggingResources = LoggingUtils.initializeDebugLogging(agentOptions.getDebugLogDirectory()); - Path logDirectory = Paths.get(new DebugLogDirectoryPropertyDefiner().getPropertyValue()); - if (FileSystemUtils.isValidPath(logDirectory.toString()) && Files.isWritable(logDirectory)) { - logger.info("Logging to " + logDirectory); - } else { - logger.warn("Could not create " + logDirectory + ". Logging to console only."); - } - } - - /** - * Initializes fallback logging in case of an error during the parsing of the options to - * {@link #premain(String, Instrumentation)} (see TS-23151). This tries to extract the logging configuration and use - * this and falls back to the default logger. - */ - private static LoggingUtils.LoggingResources initializeFallbackLogging(String premainOptions, - DelayedLogger delayedLogger) { - if (premainOptions == null) { - return LoggingUtils.initializeDefaultLogging(); - } - for (String optionPart : premainOptions.split(",")) { - if (optionPart.startsWith(AgentOptionsParser.DEBUG + "=")) { - String value = optionPart.split("=", 2)[1]; - boolean debugDisabled = value.equalsIgnoreCase("false"); - boolean debugEnabled = value.equalsIgnoreCase("true"); - if (debugDisabled) { - continue; - } - Path debugLogDirectory = null; - if (!value.isEmpty() && !debugEnabled) { - debugLogDirectory = Paths.get(value); - } - return LoggingUtils.initializeDebugLogging(debugLogDirectory); - } - if (optionPart.startsWith(AgentOptionsParser.LOGGING_CONFIG_OPTION + "=")) { - return createFallbackLoggerFromConfig(optionPart.split("=", 2)[1], delayedLogger); - } - - if (optionPart.startsWith(AgentOptionsParser.CONFIG_FILE_OPTION + "=")) { - String configFileValue = optionPart.split("=", 2)[1]; - Optional loggingConfigLine = Optional.empty(); - try { - File configFile = new FilePatternResolver(delayedLogger).parsePath( - AgentOptionsParser.CONFIG_FILE_OPTION, configFileValue).toFile(); - loggingConfigLine = FileSystemUtils.readLinesUTF8(configFile).stream() - .filter(line -> line.startsWith(AgentOptionsParser.LOGGING_CONFIG_OPTION + "=")) - .findFirst(); - } catch (IOException e) { - delayedLogger.error("Failed to load configuration from " + configFileValue + ": " + e.getMessage(), - e); - } - if (loggingConfigLine.isPresent()) { - return createFallbackLoggerFromConfig(loggingConfigLine.get().split("=", 2)[1], delayedLogger); - } - } - } - - return LoggingUtils.initializeDefaultLogging(); - } - - /** Creates a fallback logger using the given config file. */ - private static LoggingUtils.LoggingResources createFallbackLoggerFromConfig(String configLocation, - ILogger delayedLogger) { - try { - return LoggingUtils.initializeLogging( - new FilePatternResolver(delayedLogger).parsePath(AgentOptionsParser.LOGGING_CONFIG_OPTION, - configLocation)); - } catch (IOException e) { - String message = "Failed to load log configuration from location " + configLocation + ": " + e.getMessage(); - delayedLogger.error(message, e); - // output the message to console as well, as this might - // otherwise not make it to the user - System.err.println(message); - return LoggingUtils.initializeDefaultLogging(); - } - } -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/ResourceBase.java b/agent/src/main/java/com/teamscale/jacoco/agent/ResourceBase.java deleted file mode 100644 index fb539824e..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/ResourceBase.java +++ /dev/null @@ -1,145 +0,0 @@ -package com.teamscale.jacoco.agent; - -import com.teamscale.client.CommitDescriptor; -import com.teamscale.client.StringUtils; -import com.teamscale.client.TeamscaleServer; -import com.teamscale.jacoco.agent.logging.LoggingUtils; -import com.teamscale.jacoco.agent.testimpact.TestwiseCoverageAgent; -import com.teamscale.report.testwise.model.RevisionInfo; -import org.jetbrains.annotations.Contract; -import org.slf4j.Logger; - -import javax.ws.rs.BadRequestException; -import javax.ws.rs.GET; -import javax.ws.rs.PUT; -import javax.ws.rs.Path; -import javax.ws.rs.Produces; -import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.Response; -import java.util.Optional; - - -/** - * The resource of the Jersey + Jetty http server holding all the endpoints specific for the {@link AgentBase}. - */ -public abstract class ResourceBase { - - /** The logger. */ - protected final Logger logger = LoggingUtils.getLogger(this); - - /** - * The agentBase inject via {@link AgentResource#setAgent(Agent)} or - * {@link com.teamscale.jacoco.agent.testimpact.TestwiseCoverageResource#setAgent(TestwiseCoverageAgent)}. - */ - protected static AgentBase agentBase; - - /** Returns the partition for the Teamscale upload. */ - @GET - @Path("/partition") - public String getPartition() { - return Optional.ofNullable(agentBase.options.getTeamscaleServerOptions().partition).orElse(""); - } - - /** Returns the upload message for the Teamscale upload. */ - @GET - @Path("/message") - public String getMessage() { - return Optional.ofNullable(agentBase.options.getTeamscaleServerOptions().getMessage()) - .orElse(""); - } - - /** Returns revision information for the Teamscale upload. */ - @GET - @Path("/revision") - @Produces(MediaType.APPLICATION_JSON) - public RevisionInfo getRevision() { - return this.getRevisionInfo(); - } - - /** Returns revision information for the Teamscale upload. */ - @GET - @Path("/commit") - @Produces(MediaType.APPLICATION_JSON) - public RevisionInfo getCommit() { - return this.getRevisionInfo(); - } - - /** Handles setting the partition name. */ - @PUT - @Path("/partition") - public Response setPartition(String partitionString) { - String partition = StringUtils.removeDoubleQuotes(partitionString); - if (partition == null || partition.isEmpty()) { - handleBadRequest("The new partition name is missing in the request body! Please add it as plain text."); - } - - logger.debug("Changing partition name to " + partition); - agentBase.dumpReport(); - agentBase.controller.setSessionId(partition); - agentBase.options.getTeamscaleServerOptions().partition = partition; - return Response.noContent().build(); - } - - /** Handles setting the upload message. */ - @PUT - @Path("/message") - public Response setMessage(String messageString) { - String message = StringUtils.removeDoubleQuotes(messageString); - if (message == null || message.isEmpty()) { - handleBadRequest("The new message is missing in the request body! Please add it as plain text."); - } - - agentBase.dumpReport(); - logger.debug("Changing message to " + message); - agentBase.options.getTeamscaleServerOptions().setMessage(message); - - return Response.noContent().build(); - } - - /** Handles setting the revision. */ - @PUT - @Path("/revision") - public Response setRevision(String revisionString) { - String revision = StringUtils.removeDoubleQuotes(revisionString); - if (revision == null || revision.isEmpty()) { - handleBadRequest("The new revision name is missing in the request body! Please add it as plain text."); - } - - agentBase.dumpReport(); - logger.debug("Changing revision name to " + revision); - agentBase.options.getTeamscaleServerOptions().revision = revision; - - return Response.noContent().build(); - } - - /** Handles setting the upload commit. */ - @PUT - @Path("/commit") - public Response setCommit(String commitString) { - String commit = StringUtils.removeDoubleQuotes(commitString); - if (commit == null || commit.isEmpty()) { - handleBadRequest("The new upload commit is missing in the request body! Please add it as plain text."); - } - - agentBase.dumpReport(); - agentBase.options.getTeamscaleServerOptions().commit = CommitDescriptor.parse(commit); - - return Response.noContent().build(); - } - - /** Returns revision information for the Teamscale upload. */ - private RevisionInfo getRevisionInfo() { - TeamscaleServer server = agentBase.options.getTeamscaleServerOptions(); - return new RevisionInfo(server.commit, server.revision); - } - - /** - * Handles bad requests to the endpoints. - */ - @Contract(value = "_ -> fail") - protected void handleBadRequest(String message) throws BadRequestException { - logger.error(message); - throw new BadRequestException(message); - } - -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/commandline/Validator.java b/agent/src/main/java/com/teamscale/jacoco/agent/commandline/Validator.java deleted file mode 100644 index 40b7ce388..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/commandline/Validator.java +++ /dev/null @@ -1,68 +0,0 @@ -/*-------------------------------------------------------------------------+ -| | -| Copyright (c) 2009-2017 CQSE GmbH | -| | -+-------------------------------------------------------------------------*/ -package com.teamscale.jacoco.agent.commandline; - -import com.teamscale.client.StringUtils; -import com.teamscale.jacoco.agent.util.Assertions; - -import java.util.ArrayList; -import java.util.List; - -/** - * Helper class to allow for multiple validations to occur. - */ -public class Validator { - - /** The found validation problems in the form of error messages for the user. */ - private final List messages = new ArrayList<>(); - - /** Runs the given validation routine. */ - public void ensure(ExceptionBasedValidation validation) { - try { - validation.validate(); - } catch (Exception | AssertionError e) { - messages.add(e.getMessage()); - } - } - - /** - * Interface for a validation routine that throws an exception when it fails. - */ - @FunctionalInterface - public interface ExceptionBasedValidation { - - /** - * Throws an {@link Exception} or {@link AssertionError} if the validation fails. - */ - void validate() throws Exception, AssertionError; - - } - - /** - * Checks that the given condition is true or adds the given error message. - */ - public void isTrue(boolean condition, String message) { - ensure(() -> Assertions.isTrue(condition, message)); - } - - /** - * Checks that the given condition is false or adds the given error message. - */ - public void isFalse(boolean condition, String message) { - ensure(() -> Assertions.isFalse(condition, message)); - } - - /** Returns true if the validation succeeded. */ - public boolean isValid() { - return messages.isEmpty(); - } - - /** Returns an error message with all validation problems that were found. */ - public String getErrorMessage() { - return "- " + String.join(StringUtils.LINE_FEED + "- ", messages); - } - -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/CommitInfo.java b/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/CommitInfo.java deleted file mode 100644 index 82d96d480..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/CommitInfo.java +++ /dev/null @@ -1,57 +0,0 @@ -package com.teamscale.jacoco.agent.commit_resolution.git_properties; - -import com.teamscale.client.CommitDescriptor; -import com.teamscale.client.StringUtils; - -import java.util.Objects; - -/** Hold information regarding a commit. */ -public class CommitInfo { - /** The revision information (git hash). */ - public String revision; - - /** The commit descriptor. */ - public CommitDescriptor commit; - - /** - * If the commit property is set via the teamscale.commit.branch and teamscale.commit.time - * properties in a git.properties file, this should be preferred to the revision. For details see TS-38561. - */ - public boolean preferCommitDescriptorOverRevision = false; - - /** Constructor. */ - public CommitInfo(String revision, CommitDescriptor commit) { - this.revision = revision; - this.commit = commit; - } - - @Override - public String toString() { - return commit + "/" + revision; - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - CommitInfo that = (CommitInfo) o; - return Objects.equals(revision, that.revision) && Objects.equals(commit, that.commit); - } - - @Override - public int hashCode() { - return Objects.hash(revision, commit); - } - - /** - * Returns true if one of or both, revision and commit, are set - */ - public boolean isEmpty() { - return StringUtils.isEmpty(revision) && commit == null; - } -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitMultiProjectPropertiesLocator.java b/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitMultiProjectPropertiesLocator.java deleted file mode 100644 index cd41823d9..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitMultiProjectPropertiesLocator.java +++ /dev/null @@ -1,104 +0,0 @@ -package com.teamscale.jacoco.agent.commit_resolution.git_properties; - -import com.teamscale.jacoco.agent.logging.LoggingUtils; -import com.teamscale.jacoco.agent.options.ProjectAndCommit; -import com.teamscale.jacoco.agent.upload.teamscale.DelayedTeamscaleMultiProjectUploader; -import com.teamscale.jacoco.agent.util.DaemonThreadFactory; -import org.jetbrains.annotations.Nullable; -import org.jetbrains.annotations.VisibleForTesting; -import org.slf4j.Logger; - -import java.io.File; -import java.io.IOException; -import java.time.format.DateTimeFormatter; -import java.util.List; -import java.util.concurrent.Executor; -import java.util.concurrent.Executors; - -/** - * Searches a Jar/War/Ear/... file for a git.properties file in order to enable upload for the commit described therein, - * e.g. to Teamscale, via a {@link DelayedTeamscaleMultiProjectUploader}. Specifically, this searches for the - * 'teamscale.project' property specified in each of the discovered 'git.properties' files. - */ -public class GitMultiProjectPropertiesLocator implements IGitPropertiesLocator { - - private final Logger logger = LoggingUtils.getLogger(this); - - private final Executor executor; - private final DelayedTeamscaleMultiProjectUploader uploader; - - private final boolean recursiveSearch; - - private final @Nullable DateTimeFormatter gitPropertiesCommitTimeFormat; - - public GitMultiProjectPropertiesLocator(DelayedTeamscaleMultiProjectUploader uploader, boolean recursiveSearch, @Nullable DateTimeFormatter gitPropertiesCommitTimeFormat) { - // using a single threaded executor allows this class to be lock-free - this(uploader, Executors - .newSingleThreadExecutor( - new DaemonThreadFactory(GitMultiProjectPropertiesLocator.class, - "git.properties Jar scanner thread")), recursiveSearch, gitPropertiesCommitTimeFormat); - } - - public GitMultiProjectPropertiesLocator(DelayedTeamscaleMultiProjectUploader uploader, Executor executor, - boolean recursiveSearch, @Nullable DateTimeFormatter gitPropertiesCommitTimeFormat) { - this.uploader = uploader; - this.executor = executor; - this.recursiveSearch = recursiveSearch; - this.gitPropertiesCommitTimeFormat = gitPropertiesCommitTimeFormat; - } - - /** - * Asynchronously searches the given jar file for git.properties files and adds a corresponding uploader to the - * multi-project uploader. - */ - @Override - public void searchFileForGitPropertiesAsync(File file, boolean isJarFile) { - executor.execute(() -> searchFile(file, isJarFile)); - } - - /** - * Synchronously searches the given jar file for git.properties files and adds a corresponding uploader to the - * multi-project uploader. - */ - @VisibleForTesting - void searchFile(File file, boolean isJarFile) { - logger.debug("Searching file {} for multiple git.properties", file.toString()); - try { - List projectAndCommits = GitPropertiesLocatorUtils.getProjectRevisionsFromGitProperties( - file, - isJarFile, - recursiveSearch, gitPropertiesCommitTimeFormat); - if (projectAndCommits.isEmpty()) { - logger.debug("No git.properties file found in {}", file); - return; - } - - for (ProjectAndCommit projectAndCommit : projectAndCommits) { - // this code only runs when 'teamscale-project' is not given via the agent properties, - // i.e., a multi-project upload is being attempted. - // Therefore, we expect to find both the project (teamscale.project) and the revision - // (git.commit.id) in the git.properties file. - if (projectAndCommit.getProject() == null || projectAndCommit.getCommitInfo() == null) { - logger.debug( - "Found inconsistent git.properties file: the git.properties file in {} either does not specify the" + - " Teamscale project ({}) property, or does not specify the commit " + - "({}, {} + {}, or {} + {})." + - " Will skip this git.properties file and try to continue with the other ones that were found during discovery.", - file, GitPropertiesLocatorUtils.GIT_PROPERTIES_TEAMSCALE_PROJECT, - GitPropertiesLocatorUtils.GIT_PROPERTIES_GIT_COMMIT_ID, - GitPropertiesLocatorUtils.GIT_PROPERTIES_GIT_BRANCH, - GitPropertiesLocatorUtils.GIT_PROPERTIES_GIT_COMMIT_TIME, - GitPropertiesLocatorUtils.GIT_PROPERTIES_TEAMSCALE_COMMIT_BRANCH, - GitPropertiesLocatorUtils.GIT_PROPERTIES_TEAMSCALE_COMMIT_TIME); - continue; - } - logger.debug("Found git.properties file in {} and found Teamscale project {} and revision {}", file, - projectAndCommit.getProject(), projectAndCommit.getCommitInfo()); - uploader.addTeamscaleProjectAndCommit(file, projectAndCommit); - } - } catch (IOException | InvalidGitPropertiesException e) { - logger.error("Error during asynchronous search for git.properties in {}", file, e); - } - } - -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitPropertiesLocatingTransformer.java b/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitPropertiesLocatingTransformer.java deleted file mode 100644 index 2b430047e..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitPropertiesLocatingTransformer.java +++ /dev/null @@ -1,87 +0,0 @@ -package com.teamscale.jacoco.agent.commit_resolution.git_properties; - -import com.teamscale.client.StringUtils; -import com.teamscale.jacoco.agent.logging.LoggingUtils; -import com.teamscale.report.util.ClasspathWildcardIncludeFilter; -import kotlin.Pair; -import org.slf4j.Logger; - -import java.io.File; -import java.lang.instrument.ClassFileTransformer; -import java.net.URL; -import java.security.CodeSource; -import java.security.ProtectionDomain; -import java.util.Set; -import java.util.concurrent.ConcurrentSkipListSet; - -/** - * {@link ClassFileTransformer} that doesn't change the loaded classes but searches their corresponding Jar/War/Ear/... - * files for a git.properties file. - */ -public class GitPropertiesLocatingTransformer implements ClassFileTransformer { - - private final Logger logger = LoggingUtils.getLogger(this); - private final Set seenJars = new ConcurrentSkipListSet<>(); - private final IGitPropertiesLocator locator; - private final ClasspathWildcardIncludeFilter locationIncludeFilter; - - public GitPropertiesLocatingTransformer(IGitPropertiesLocator locator, - ClasspathWildcardIncludeFilter locationIncludeFilter) { - this.locator = locator; - this.locationIncludeFilter = locationIncludeFilter; - } - - @Override - public byte[] transform(ClassLoader classLoader, String className, Class aClass, - ProtectionDomain protectionDomain, byte[] classFileContent) { - if (protectionDomain == null) { - // happens for e.g. java.lang. We can ignore these classes - return null; - } - - if (StringUtils.isEmpty(className) || !locationIncludeFilter.isIncluded(className)) { - // only search in jar files of included classes - return null; - } - - try { - CodeSource codeSource = protectionDomain.getCodeSource(); - if (codeSource == null || codeSource.getLocation() == null) { - // unknown when this can happen, we suspect when code is generated at runtime - // but there's nothing else we can do here in either case. - // codeSource.getLocation() is null e.g. when executing Pixelitor with Java14 for class sun/reflect/misc/Trampoline - logger.debug("Could not locate code source for class {}. Skipping git.properties search for this class", - className); - return null; - } - - URL jarOrClassFolderUrl = codeSource.getLocation(); - Pair searchRoot = GitPropertiesLocatorUtils.extractGitPropertiesSearchRoot( - jarOrClassFolderUrl); - if (searchRoot == null || searchRoot.getFirst() == null) { - logger.warn("Not searching location for git.properties with unknown protocol or extension {}." + - " If this location contains your git.properties, please report this warning as a" + - " bug to CQSE. In that case, auto-discovery of git.properties will not work.", - jarOrClassFolderUrl); - return null; - } - - if (hasLocationAlreadyBeenSearched(searchRoot.getFirst())) { - return null; - } - - logger.debug("Scheduling asynchronous search for git.properties in {}", searchRoot); - locator.searchFileForGitPropertiesAsync(searchRoot.getFirst(), searchRoot.getSecond()); - } catch (Throwable e) { - // we catch Throwable to be sure that we log all errors as anything thrown from this method is - // silently discarded by the JVM - logger.error("Failed to process class {} in search of git.properties", className, e); - } - return null; - } - - private boolean hasLocationAlreadyBeenSearched(File location) { - return !seenJars.add(location.toString()); - } - -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitPropertiesLocatorUtils.java b/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitPropertiesLocatorUtils.java deleted file mode 100644 index 0d4080b06..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitPropertiesLocatorUtils.java +++ /dev/null @@ -1,462 +0,0 @@ -package com.teamscale.jacoco.agent.commit_resolution.git_properties; - -import com.teamscale.client.CommitDescriptor; -import com.teamscale.client.FileSystemUtils; -import com.teamscale.client.StringUtils; -import com.teamscale.jacoco.agent.options.ProjectAndCommit; -import com.teamscale.report.util.BashFileSkippingInputStream; -import kotlin.Pair; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.io.File; -import java.io.IOException; -import java.io.InputStream; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; -import java.net.URISyntaxException; -import java.net.URL; -import java.nio.file.Files; -import java.nio.file.Paths; -import java.time.ZonedDateTime; -import java.time.format.DateTimeFormatter; -import java.time.format.DateTimeFormatterBuilder; -import java.time.format.DateTimeParseException; -import java.util.ArrayList; -import java.util.List; -import java.util.Properties; -import java.util.jar.JarEntry; -import java.util.jar.JarInputStream; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -/** Utility methods to extract certain properties from git.properties files in archives and folders. */ -public class GitPropertiesLocatorUtils { - - /** Name of the git.properties file. */ - public static final String GIT_PROPERTIES_FILE_NAME = "git.properties"; - - /** The git.properties key that holds the commit time. */ - public static final String GIT_PROPERTIES_GIT_COMMIT_TIME = "git.commit.time"; - - /** The git.properties key that holds the commit branch. */ - public static final String GIT_PROPERTIES_GIT_BRANCH = "git.branch"; - - /** The git.properties key that holds the commit hash. */ - public static final String GIT_PROPERTIES_GIT_COMMIT_ID = "git.commit.id"; - - /** - * Alternative git.properties key that might also hold the commit hash, depending on the Maven git-commit-id plugin - * configuration. - */ - public static final String GIT_PROPERTIES_GIT_COMMIT_ID_FULL = "git.commit.id.full"; - - /** - * You can provide a teamscale timestamp in git.properties files to overwrite the revision. See TS-38561. - */ - public static final String GIT_PROPERTIES_TEAMSCALE_COMMIT_BRANCH = "teamscale.commit.branch"; - - /** - * You can provide a teamscale timestamp in git.properties files to overwrite the revision. See TS-38561. - */ - public static final String GIT_PROPERTIES_TEAMSCALE_COMMIT_TIME = "teamscale.commit.time"; - - /** The git.properties key that holds the Teamscale project name. */ - public static final String GIT_PROPERTIES_TEAMSCALE_PROJECT = "teamscale.project"; - - /** Matches the path to the jar file in a jar:file: URL in regex group 1. */ - private static final Pattern JAR_URL_REGEX = Pattern.compile("jar:(?:file|nested):(.*?)!.*", - Pattern.CASE_INSENSITIVE); - - private static final Pattern NESTED_JAR_REGEX = Pattern.compile("[jwea]ar:file:(.*?)\\*(.*)", - Pattern.CASE_INSENSITIVE); - - /** - * Defined in GitCommitIdMojo - */ - private static final String GIT_PROPERTIES_DEFAULT_MAVEN_DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ssXXX"; - - /** - * Defined in GitPropertiesPlugin - */ - private static final String GIT_PROPERTIES_DEFAULT_GRADLE_DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ssZ"; - - /** - * Reads the git SHA1 and branch and timestamp from the given jar file's git.properties and builds a commit - * descriptor out of it. If no git.properties file can be found, returns null. - * - * @throws IOException If reading the jar file fails. - * @throws InvalidGitPropertiesException If a git.properties file is found but it is malformed. - */ - public static List getCommitInfoFromGitProperties(File file, boolean isJarFile, - boolean recursiveSearch, - @Nullable DateTimeFormatter gitPropertiesCommitTimeFormat) - throws IOException, InvalidGitPropertiesException { - List> entriesWithProperties = GitPropertiesLocatorUtils.findGitPropertiesInFile(file, - isJarFile, recursiveSearch); - List result = new ArrayList<>(); - - for (Pair entryWithProperties : entriesWithProperties) { - String entry = entryWithProperties.getFirst(); - Properties properties = entryWithProperties.getSecond(); - - CommitInfo commitInfo = GitPropertiesLocatorUtils.getCommitInfoFromGitProperties(properties, entry, file, - gitPropertiesCommitTimeFormat); - result.add(commitInfo); - } - - return result; - } - - /** - * Tries to extract a file system path to a search root for the git.properties search. A search root is either a - * file system folder or a Jar file. If no such path can be extracted, returns null. - * - * @throws URISyntaxException under certain circumstances if parsing the URL fails. This should be treated the same - * as a null search result but the exception is preserved so it can be logged. - */ - public static Pair extractGitPropertiesSearchRoot( - URL jarOrClassFolderUrl) throws URISyntaxException, IOException, NoSuchMethodException, - IllegalAccessException, InvocationTargetException { - String protocol = jarOrClassFolderUrl.getProtocol().toLowerCase(); - switch (protocol) { - case "file": - File jarOrClassFolderFile = new File(jarOrClassFolderUrl.toURI()); - if (jarOrClassFolderFile.isDirectory() || isJarLikeFile(jarOrClassFolderUrl.getPath())) { - return new Pair<>(new File(jarOrClassFolderUrl.toURI()), !jarOrClassFolderFile.isDirectory()); - } - break; - case "jar": - // Used e.g. by Spring Boot. Example: jar:file:/home/k/demo.jar!/BOOT-INF/classes!/ - Matcher jarMatcher = JAR_URL_REGEX.matcher(jarOrClassFolderUrl.toString()); - if (jarMatcher.matches()) { - return new Pair<>(new File(jarMatcher.group(1)), true); - } - // Intentionally no break to handle ear and war files - case "war": - case "ear": - // Used by some web applications and potentially fat jars. - // Example: war:file:/Users/example/apache-tomcat/webapps/demo.war*/WEB-INF/lib/demoLib-1.0-SNAPSHOT.jar - Matcher nestedMatcher = NESTED_JAR_REGEX.matcher(jarOrClassFolderUrl.toString()); - if (nestedMatcher.matches()) { - return new Pair<>(new File(nestedMatcher.group(1)), true); - } - break; - case "vfs": - return getVfsContentFolder(jarOrClassFolderUrl); - default: - return null; - } - return null; - } - - /** - * VFS (Virtual File System) protocol is used by JBoss EAP and Wildfly. Example of an URL: - * vfs:/content/helloworld.war/WEB-INF/classes - */ - private static Pair getVfsContentFolder( - URL jarOrClassFolderUrl) throws IOException, NoSuchMethodException, IllegalAccessException, InvocationTargetException { - // we obtain the URL of a specific class file as input, e.g., - // vfs:/content/helloworld.war/WEB-INF/classes - // Next, we try to extract the artefact URL from it, e.g., vfs:/content/helloworld.war - String artefactUrl = extractArtefactUrl(jarOrClassFolderUrl); - - Object virtualFile = new URL(artefactUrl).openConnection().getContent(); - Class virtualFileClass = virtualFile.getClass(); - // obtain the physical location of the class file. It is created on demand in /standalone/tmp/vfs - Method getPhysicalFileMethod = virtualFileClass.getMethod("getPhysicalFile"); - File file = (File) getPhysicalFileMethod.invoke(virtualFile); - return new Pair<>(file, !file.isDirectory()); - } - - /** - * Extracts the artefact URL (e.g., vfs:/content/helloworld.war/) from the full URL of the class file (e.g., - * vfs:/content/helloworld.war/WEB-INF/classes). - */ - private static String extractArtefactUrl(URL jarOrClassFolderUrl) { - String url = jarOrClassFolderUrl.getPath().toLowerCase(); - String[] pathSegments = url.split("/"); - StringBuilder artefactUrlBuilder = new StringBuilder("vfs:"); - int segmentIdx = 0; - while (segmentIdx < pathSegments.length) { - String segment = pathSegments[segmentIdx]; - artefactUrlBuilder.append(segment); - artefactUrlBuilder.append("/"); - if (isJarLikeFile(segment)) { - break; - } - segmentIdx += 1; - } - if (segmentIdx == pathSegments.length) { - return url; - } - return artefactUrlBuilder.toString(); - } - - private static boolean isJarLikeFile(String segment) { - return StringUtils.endsWithOneOf( - segment.toLowerCase(), ".jar", ".war", ".ear", ".aar"); - } - - /** - * Reads the 'teamscale.project' property values and the git SHA1s or branch + timestamp from all git.properties - * files contained in the provided folder or archive file. - * - * @throws IOException If reading the jar file fails. - * @throws InvalidGitPropertiesException If a git.properties file is found but it is malformed. - */ - public static List getProjectRevisionsFromGitProperties( - File file, boolean isJarFile, boolean recursiveSearch, - @Nullable DateTimeFormatter gitPropertiesCommitTimeFormat) throws IOException, InvalidGitPropertiesException { - List> entriesWithProperties = findGitPropertiesInFile(file, isJarFile, - recursiveSearch); - List result = new ArrayList<>(); - for (Pair entryWithProperties : entriesWithProperties) { - CommitInfo commitInfo = getCommitInfoFromGitProperties(entryWithProperties.getSecond(), - entryWithProperties.getFirst(), file, gitPropertiesCommitTimeFormat); - String project = entryWithProperties.getSecond().getProperty(GIT_PROPERTIES_TEAMSCALE_PROJECT); - if (commitInfo.isEmpty() && StringUtils.isEmpty(project)) { - throw new InvalidGitPropertiesException( - "No entry or empty value for both '" + GIT_PROPERTIES_GIT_COMMIT_ID + "'/'" + GIT_PROPERTIES_GIT_COMMIT_ID_FULL + - "' and '" + GIT_PROPERTIES_TEAMSCALE_PROJECT + "' in " + file + "." + - "\nContents of " + GIT_PROPERTIES_FILE_NAME + ": " + entryWithProperties.getSecond() - ); - } - result.add(new ProjectAndCommit(project, commitInfo)); - } - return result; - } - - /** - * Returns pairs of paths to git.properties files and their parsed properties found in the provided folder or - * archive file. Nested jar files will also be searched recursively if specified. - */ - public static List> findGitPropertiesInFile( - File file, boolean isJarFile, boolean recursiveSearch) throws IOException { - if (isJarFile) { - return findGitPropertiesInArchiveFile(file, recursiveSearch); - } - return findGitPropertiesInDirectoryFile(file, recursiveSearch); - } - - /** - * Searches for git properties in jar/war/ear/aar files - */ - private static List> findGitPropertiesInArchiveFile(File file, - boolean recursiveSearch) throws IOException { - try (JarInputStream jarStream = new JarInputStream( - new BashFileSkippingInputStream(Files.newInputStream(file.toPath())))) { - return findGitPropertiesInArchive(jarStream, file.getName(), recursiveSearch); - } catch (IOException e) { - throw new IOException("Reading jar " + file.getAbsolutePath() + " for obtaining commit " + - "descriptor from git.properties failed", e); - } - } - - /** - * Searches for git.properties file in the given folder - * - * @param recursiveSearch If enabled, git.properties files will also be searched in jar files - */ - private static List> findGitPropertiesInDirectoryFile( - File directoryFile, boolean recursiveSearch) throws IOException { - List> result = new ArrayList<>(findGitPropertiesInFolder(directoryFile)); - - if (recursiveSearch) { - result.addAll(findGitPropertiesInNestedJarFiles(directoryFile)); - } - - return result; - } - - /** - * Finds all jar files in the given folder and searches them recursively for git.properties - */ - private static List> findGitPropertiesInNestedJarFiles( - File directoryFile) throws IOException { - List> result = new ArrayList<>(); - List jarFiles = FileSystemUtils.listFilesRecursively(directoryFile, - file -> isJarLikeFile(file.getName())); - for (File jarFile : jarFiles) { - JarInputStream is = new JarInputStream(Files.newInputStream(jarFile.toPath())); - String relativeFilePath = directoryFile.getName() + File.separator + directoryFile.toPath() - .relativize(jarFile.toPath()); - result.addAll(findGitPropertiesInArchive(is, relativeFilePath, true)); - } - return result; - } - - /** - * Searches for git.properties files in the given folder - */ - private static List> findGitPropertiesInFolder(File directoryFile) throws IOException { - List> result = new ArrayList<>(); - List gitPropertiesFiles = FileSystemUtils.listFilesRecursively(directoryFile, - file -> file.getName().equalsIgnoreCase(GIT_PROPERTIES_FILE_NAME)); - for (File gitPropertiesFile : gitPropertiesFiles) { - try (InputStream is = Files.newInputStream(gitPropertiesFile.toPath())) { - Properties gitProperties = new Properties(); - gitProperties.load(is); - String relativeFilePath = directoryFile.getName() + File.separator + directoryFile.toPath() - .relativize(gitPropertiesFile.toPath()); - result.add(new Pair<>(relativeFilePath, gitProperties)); - } catch (IOException e) { - throw new IOException( - "Reading directory " + gitPropertiesFile.getAbsolutePath() + " for obtaining commit " + - "descriptor from git.properties failed", e); - } - } - return result; - } - - /** - * Returns pairs of paths to git.properties files and their parsed properties found in the provided JarInputStream. - * Nested jar files will also be searched recursively if specified. - */ - static List> findGitPropertiesInArchive( - JarInputStream in, String archiveName, boolean recursiveSearch) throws IOException { - List> result = new ArrayList<>(); - JarEntry entry; - boolean isEmpty = true; - - while ((entry = in.getNextJarEntry()) != null) { - isEmpty = false; - String fullEntryName = archiveName + File.separator + entry.getName(); - if (Paths.get(entry.getName()).getFileName().toString().equalsIgnoreCase(GIT_PROPERTIES_FILE_NAME)) { - Properties gitProperties = new Properties(); - gitProperties.load(in); - result.add(new Pair<>(fullEntryName, gitProperties)); - } else if (isJarLikeFile(entry.getName()) && recursiveSearch) { - result.addAll(findGitPropertiesInArchive(new JarInputStream(in), fullEntryName, true)); - } - } - if (isEmpty) { - throw new IOException( - "No entries in Jar file " + archiveName + ". Is this a valid jar file?. If so, please report to CQSE."); - } - return result; - } - - /** - * Returns the CommitInfo (revision and branch + timestmap) from a git properties file. The revision can be either - * in {@link #GIT_PROPERTIES_GIT_COMMIT_ID} or {@link #GIT_PROPERTIES_GIT_COMMIT_ID_FULL}. The branch and timestamp - * in {@link #GIT_PROPERTIES_GIT_BRANCH} + {@link #GIT_PROPERTIES_GIT_COMMIT_TIME} or in - * {@link #GIT_PROPERTIES_TEAMSCALE_COMMIT_BRANCH} + {@link #GIT_PROPERTIES_TEAMSCALE_COMMIT_TIME}. By default, - * times will be parsed with {@link #GIT_PROPERTIES_DEFAULT_GRADLE_DATE_FORMAT} and - * {@link #GIT_PROPERTIES_DEFAULT_MAVEN_DATE_FORMAT}. An additional format can be given with - * {@code dateTimeFormatter} - */ - public static CommitInfo getCommitInfoFromGitProperties( - Properties gitProperties, String entryName, File jarFile, - @Nullable DateTimeFormatter additionalDateTimeFormatter) throws InvalidGitPropertiesException { - - DateTimeFormatter dateTimeFormatter = createDateTimeFormatter(additionalDateTimeFormatter); - - // Get Revision - String revision = getRevisionFromGitProperties(gitProperties); - - // Get branch and timestamp from git.commit.branch and git.commit.id - CommitDescriptor commitDescriptor = getCommitDescriptorFromDefaultGitPropertyValues(gitProperties, entryName, - jarFile, dateTimeFormatter); - // When read from these properties, we should prefer to upload to the revision - boolean preferCommitDescriptorOverRevision = false; - - - // Get branch and timestamp from teamscale.commit.branch and teamscale.commit.time (TS-38561) - CommitDescriptor teamscaleTimestampBasedCommitDescriptor = getCommitDescriptorFromTeamscaleTimestampProperty( - gitProperties, entryName, jarFile, dateTimeFormatter); - if (teamscaleTimestampBasedCommitDescriptor != null) { - // In this case, as we specifically set this property, we should prefer branch and timestamp to the revision - preferCommitDescriptorOverRevision = true; - commitDescriptor = teamscaleTimestampBasedCommitDescriptor; - } - - if (StringUtils.isEmpty(revision) && commitDescriptor == null) { - throw new InvalidGitPropertiesException( - "No entry or invalid value for '" + GIT_PROPERTIES_GIT_COMMIT_ID + "', '" + GIT_PROPERTIES_GIT_COMMIT_ID_FULL + - "', '" + GIT_PROPERTIES_TEAMSCALE_COMMIT_BRANCH + "' and " + GIT_PROPERTIES_TEAMSCALE_COMMIT_TIME + "'\n" + - "Location: Entry '" + entryName + "' in jar file '" + jarFile + "'." + - "\nContents of " + GIT_PROPERTIES_FILE_NAME + ":\n" + gitProperties); - } - - CommitInfo commitInfo = new CommitInfo(revision, commitDescriptor); - commitInfo.preferCommitDescriptorOverRevision = preferCommitDescriptorOverRevision; - return commitInfo; - } - - private static @NotNull DateTimeFormatter createDateTimeFormatter( - @org.jetbrains.annotations.Nullable DateTimeFormatter additionalDateTimeFormatter) { - DateTimeFormatter defaultDateTimeFormatter = DateTimeFormatter.ofPattern( - String.format("[%s][%s]", GIT_PROPERTIES_DEFAULT_MAVEN_DATE_FORMAT, - GIT_PROPERTIES_DEFAULT_GRADLE_DATE_FORMAT)); - DateTimeFormatterBuilder builder = new DateTimeFormatterBuilder().append(defaultDateTimeFormatter); - if (additionalDateTimeFormatter != null) { - builder.append(additionalDateTimeFormatter); - } - return builder.toFormatter(); - } - - private static String getRevisionFromGitProperties(Properties gitProperties) { - String revision = gitProperties.getProperty(GIT_PROPERTIES_GIT_COMMIT_ID); - if (StringUtils.isEmpty(revision)) { - revision = gitProperties.getProperty(GIT_PROPERTIES_GIT_COMMIT_ID_FULL); - } - return revision; - } - - private static CommitDescriptor getCommitDescriptorFromTeamscaleTimestampProperty(Properties gitProperties, - String entryName, File jarFile, - DateTimeFormatter dateTimeFormatter) throws InvalidGitPropertiesException { - String teamscaleCommitBranch = gitProperties.getProperty(GIT_PROPERTIES_TEAMSCALE_COMMIT_BRANCH); - String teamscaleCommitTime = gitProperties.getProperty(GIT_PROPERTIES_TEAMSCALE_COMMIT_TIME); - - if (StringUtils.isEmpty(teamscaleCommitBranch) || StringUtils.isEmpty(teamscaleCommitTime)) { - return null; - } - - String teamscaleTimestampRegex = "\\d*(?:p\\d*)?"; - Matcher teamscaleTimestampMatcher = Pattern.compile(teamscaleTimestampRegex).matcher(teamscaleCommitTime); - if (teamscaleTimestampMatcher.matches()) { - return new CommitDescriptor(teamscaleCommitBranch, teamscaleCommitTime); - } - - long epochTimestamp; - try { - epochTimestamp = ZonedDateTime.parse(teamscaleCommitTime, dateTimeFormatter).toInstant().toEpochMilli(); - } catch (DateTimeParseException e) { - throw new InvalidGitPropertiesException( - "Cannot parse commit time '" + teamscaleCommitTime + "' in the '" + GIT_PROPERTIES_TEAMSCALE_COMMIT_TIME + - "' property. It needs to be in the date formats '" + GIT_PROPERTIES_DEFAULT_MAVEN_DATE_FORMAT + - "' or '" + GIT_PROPERTIES_DEFAULT_GRADLE_DATE_FORMAT + "' or match the Teamscale timestamp format '" - + teamscaleTimestampRegex + "'." + - "\nLocation: Entry '" + entryName + "' in jar file '" + jarFile + "'." + - "\nContents of " + GIT_PROPERTIES_FILE_NAME + ":\n" + gitProperties, e); - } - - return new CommitDescriptor(teamscaleCommitBranch, epochTimestamp); - } - - private static CommitDescriptor getCommitDescriptorFromDefaultGitPropertyValues(Properties gitProperties, - String entryName, File jarFile, - DateTimeFormatter dateTimeFormatter) throws InvalidGitPropertiesException { - String gitBranch = gitProperties.getProperty(GIT_PROPERTIES_GIT_BRANCH); - String gitTime = gitProperties.getProperty(GIT_PROPERTIES_GIT_COMMIT_TIME); - if (!StringUtils.isEmpty(gitBranch) && !StringUtils.isEmpty(gitTime)) { - long gitTimestamp; - try { - gitTimestamp = ZonedDateTime.parse(gitTime, dateTimeFormatter).toInstant().toEpochMilli(); - } catch (DateTimeParseException e) { - throw new InvalidGitPropertiesException( - "Could not parse the timestamp in property '" + GIT_PROPERTIES_GIT_COMMIT_TIME + "'." + - "\nLocation: Entry '" + entryName + "' in jar file '" + jarFile + "'." + - "\nContents of " + GIT_PROPERTIES_FILE_NAME + ":\n" + gitProperties, e); - } - return new CommitDescriptor(gitBranch, gitTimestamp); - } - return null; - } -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitSingleProjectPropertiesLocator.java b/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitSingleProjectPropertiesLocator.java deleted file mode 100644 index e9b654670..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitSingleProjectPropertiesLocator.java +++ /dev/null @@ -1,115 +0,0 @@ -package com.teamscale.jacoco.agent.commit_resolution.git_properties; - -import com.teamscale.jacoco.agent.logging.LoggingUtils; -import com.teamscale.jacoco.agent.upload.delay.DelayedUploader; -import com.teamscale.jacoco.agent.util.DaemonThreadFactory; -import org.jetbrains.annotations.Nullable; -import org.slf4j.Logger; - -import java.io.File; -import java.io.IOException; -import java.time.format.DateTimeFormatter; -import java.util.List; -import java.util.concurrent.Executor; -import java.util.concurrent.Executors; - -/** - * Searches a Jar/War/Ear/... file for a git.properties file in order to enable upload for the commit described therein, - * e.g. to Teamscale, via a {@link DelayedUploader}. - */ -public class GitSingleProjectPropertiesLocator implements IGitPropertiesLocator { - - private final Logger logger = LoggingUtils.getLogger(this); - private final Executor executor; - private T foundData = null; - private File jarFileWithGitProperties = null; - - private final DelayedUploader uploader; - private final DataExtractor dataExtractor; - - private final boolean recursiveSearch; - private final @Nullable DateTimeFormatter gitPropertiesCommitTimeFormat; - - public GitSingleProjectPropertiesLocator(DelayedUploader uploader, DataExtractor dataExtractor, - boolean recursiveSearch, - @Nullable DateTimeFormatter gitPropertiesCommitTimeFormat) { - // using a single threaded executor allows this class to be lock-free - this(uploader, dataExtractor, Executors - .newSingleThreadExecutor( - new DaemonThreadFactory(GitSingleProjectPropertiesLocator.class, - "git.properties Jar scanner thread")), - recursiveSearch, gitPropertiesCommitTimeFormat); - } - - /** - * Visible for testing. Allows tests to control the {@link Executor} in order to test the asynchronous functionality - * of this class. - */ - public GitSingleProjectPropertiesLocator(DelayedUploader uploader, DataExtractor dataExtractor, - Executor executor, - boolean recursiveSearch, - @Nullable DateTimeFormatter gitPropertiesCommitTimeFormat) { - this.uploader = uploader; - this.dataExtractor = dataExtractor; - this.executor = executor; - this.recursiveSearch = recursiveSearch; - this.gitPropertiesCommitTimeFormat = gitPropertiesCommitTimeFormat; - } - - /** - * Asynchronously searches the given jar file for a git.properties file. - */ - @Override - public void searchFileForGitPropertiesAsync(File file, boolean isJarFile) { - executor.execute(() -> searchFile(file, isJarFile)); - } - - private void searchFile(File file, boolean isJarFile) { - logger.debug("Searching jar file {} for a single git.properties", file); - try { - List data = dataExtractor.extractData(file, isJarFile, recursiveSearch, gitPropertiesCommitTimeFormat); - if (data.isEmpty()) { - logger.debug("No git.properties files found in {}", file.toString()); - return; - } - if (data.size() > 1) { - logger.warn("Multiple git.properties files found in {}", file.toString() + - ". Using the first one: " + data.get(0)); - - } - T dataEntry = data.get(0); - - if (foundData != null) { - if (!foundData.equals(dataEntry)) { - logger.warn( - "Found inconsistent git.properties files: {} contained data {} while {} contained {}." + - " Please ensure that all git.properties files of your application are consistent." + - " Otherwise, you may" + - " be uploading to the wrong project/commit which will result in incorrect coverage data" + - " displayed in Teamscale. If you cannot fix the inconsistency, you can manually" + - " specify a Jar/War/Ear/... file from which to read the correct git.properties" + - " file with the agent's teamscale-git-properties-jar parameter.", - jarFileWithGitProperties, foundData, file, data); - } - return; - } - - logger.debug("Found git.properties file in {} and found commit descriptor {}", file.toString(), - dataEntry); - foundData = dataEntry; - jarFileWithGitProperties = file; - uploader.setCommitAndTriggerAsynchronousUpload(dataEntry); - } catch (IOException | InvalidGitPropertiesException e) { - logger.error("Error during asynchronous search for git.properties in {}", file.toString(), e); - } - } - - /** Functional interface for data extraction from a jar file. */ - @FunctionalInterface - public interface DataExtractor { - /** Extracts data from the JAR. */ - List extractData(File file, boolean isJarFile, - boolean recursiveSearch, - @Nullable DateTimeFormatter gitPropertiesCommitTimeFormat) throws IOException, InvalidGitPropertiesException; - } -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/InvalidGitPropertiesException.java b/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/InvalidGitPropertiesException.java deleted file mode 100644 index d2491d43c..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/InvalidGitPropertiesException.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.teamscale.jacoco.agent.commit_resolution.git_properties; - -/** - * Thrown in case a git.properties file is found but it is malformed. - */ -public class InvalidGitPropertiesException extends Exception { - /*package*/ InvalidGitPropertiesException(String s, Throwable throwable) { - super(s, throwable); - } - - public InvalidGitPropertiesException(String s) { - super(s); - } -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/sapnwdi/NwdiMarkerClassLocatingTransformer.java b/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/sapnwdi/NwdiMarkerClassLocatingTransformer.java deleted file mode 100644 index 87d04b6f8..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/sapnwdi/NwdiMarkerClassLocatingTransformer.java +++ /dev/null @@ -1,92 +0,0 @@ -package com.teamscale.jacoco.agent.commit_resolution.sapnwdi; - -import com.teamscale.client.CommitDescriptor; -import com.teamscale.client.StringUtils; -import com.teamscale.jacoco.agent.logging.LoggingUtils; -import com.teamscale.jacoco.agent.options.sapnwdi.DelayedSapNwdiMultiUploader; -import com.teamscale.jacoco.agent.options.sapnwdi.SapNwdiApplication; -import com.teamscale.report.util.ClasspathWildcardIncludeFilter; -import org.slf4j.Logger; - -import java.lang.instrument.ClassFileTransformer; -import java.net.URL; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.nio.file.attribute.BasicFileAttributes; -import java.security.CodeSource; -import java.security.ProtectionDomain; -import java.util.Collection; -import java.util.Map; -import java.util.stream.Collectors; - -/** - * {@link ClassFileTransformer} that doesn't change the loaded classes but guesses the rough commit timestamp by - * inspecting the last modification date of the applications marker class file. - */ -public class NwdiMarkerClassLocatingTransformer implements ClassFileTransformer { - - /** The Design time repository-git-bridge (DTR-bridge) currently only exports a single branch named master. */ - private static final String DTR_BRIDGE_DEFAULT_BRANCH = "master"; - private final Logger logger = LoggingUtils.getLogger(this); - private final DelayedSapNwdiMultiUploader store; - private final ClasspathWildcardIncludeFilter locationIncludeFilter; - private final Map markerClassesToApplications; - - public NwdiMarkerClassLocatingTransformer( - DelayedSapNwdiMultiUploader store, - ClasspathWildcardIncludeFilter locationIncludeFilter, - Collection apps) { - this.store = store; - this.locationIncludeFilter = locationIncludeFilter; - this.markerClassesToApplications = apps.stream().collect( - Collectors.toMap(sapNwdiApplication -> sapNwdiApplication.getMarkerClass().replace('.', '/'), - application -> application)); - } - - @Override - public byte[] transform(ClassLoader classLoader, String className, Class aClass, - ProtectionDomain protectionDomain, byte[] classFileContent) { - if (protectionDomain == null) { - // happens for e.g. java.lang. We can ignore these classes - return null; - } - - if (StringUtils.isEmpty(className) || !locationIncludeFilter.isIncluded(className)) { - // only search in jar files of included classes - return null; - } - - if (!this.markerClassesToApplications.containsKey(className)) { - // only kick off search if the marker class was found. - return null; - } - - try { - CodeSource codeSource = protectionDomain.getCodeSource(); - if (codeSource == null) { - // unknown when this can happen, we suspect when code is generated at runtime - // but there's nothing else we can do here in either case - return null; - } - - URL jarOrClassFolderUrl = codeSource.getLocation(); - logger.debug("Found " + className + " in " + jarOrClassFolderUrl); - - if (jarOrClassFolderUrl.getProtocol().equalsIgnoreCase("file")) { - Path file = Paths.get(jarOrClassFolderUrl.toURI()); - BasicFileAttributes attr = Files.readAttributes(file, BasicFileAttributes.class); - SapNwdiApplication application = markerClassesToApplications.get(className); - CommitDescriptor commitDescriptor = new CommitDescriptor( - DTR_BRIDGE_DEFAULT_BRANCH, attr.lastModifiedTime().toMillis()); - store.setCommitForApplication(commitDescriptor, application); - } - } catch (Throwable e) { - // we catch Throwable to be sure that we log all errors as anything thrown from this method is - // silently discarded by the JVM - logger.error("Failed to process class {} trying to determine its last modification timestamp.", className, - e); - } - return null; - } -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/configuration/AgentOptionReceiveException.java b/agent/src/main/java/com/teamscale/jacoco/agent/configuration/AgentOptionReceiveException.java deleted file mode 100644 index 80f9bb19d..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/configuration/AgentOptionReceiveException.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.teamscale.jacoco.agent.configuration; - -/** Thrown when retrieving the profiler configuration from Teamscale fails. */ -public class AgentOptionReceiveException extends Exception { - - /** - * Serialization ID. - */ - private static final long serialVersionUID = 1L; - - /** - * Constructor. - */ - public AgentOptionReceiveException(String message) { - super(message); - } - - /** - * Constructor. - */ - public AgentOptionReceiveException(String message, Throwable cause) { - super(message, cause); - } - -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/configuration/ConfigurationViaTeamscale.java b/agent/src/main/java/com/teamscale/jacoco/agent/configuration/ConfigurationViaTeamscale.java deleted file mode 100644 index d4e59a7e6..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/configuration/ConfigurationViaTeamscale.java +++ /dev/null @@ -1,168 +0,0 @@ -package com.teamscale.jacoco.agent.configuration; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.teamscale.client.ITeamscaleService; -import com.teamscale.client.JsonUtils; -import com.teamscale.client.ProcessInformation; -import com.teamscale.client.ProfilerConfiguration; -import com.teamscale.client.ProfilerInfo; -import com.teamscale.client.ProfilerRegistration; -import com.teamscale.client.TeamscaleServiceGenerator; -import com.teamscale.jacoco.agent.logging.LoggingUtils; -import com.teamscale.jacoco.agent.util.AgentUtils; -import com.teamscale.report.util.ILogger; -import okhttp3.HttpUrl; -import okhttp3.ResponseBody; -import org.jetbrains.annotations.NotNull; -import retrofit2.Response; - -import java.io.IOException; -import java.time.Duration; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; - -/** - * Responsible for holding the configuration that was retrieved from Teamscale and sending regular heartbeat events to - * keep the profiler information in Teamscale up to date. - */ -public class ConfigurationViaTeamscale { - - /** - * Two minute timeout. This is quite high to account for an eventual high load on the Teamscale server. This is a - * tradeoff between fast application startup and potentially missing test coverage. - */ - private static final Duration LONG_TIMEOUT = Duration.ofMinutes(2); - - /** - * The UUID that Teamscale assigned to this instance of the profiler during the registration. This ID needs to be - * used when communicating with Teamscale. - */ - private final String profilerId; - - private final ITeamscaleService teamscaleClient; - private final ProfilerInfo profilerInfo; - - public ConfigurationViaTeamscale(ITeamscaleService teamscaleClient, ProfilerRegistration profilerRegistration, - ProcessInformation processInformation) { - this.teamscaleClient = teamscaleClient; - this.profilerId = profilerRegistration.profilerId; - this.profilerInfo = new ProfilerInfo(processInformation, profilerRegistration.profilerConfiguration); - } - - /** - * Tries to retrieve the profiler configuration from Teamscale. In case retrieval fails the method throws a - * {@link AgentOptionReceiveException}. - */ - public static @NotNull ConfigurationViaTeamscale retrieve(ILogger logger, String configurationId, HttpUrl url, - String userName, - String userAccessToken) throws AgentOptionReceiveException { - ITeamscaleService teamscaleClient = TeamscaleServiceGenerator - .createService(ITeamscaleService.class, url, userName, userAccessToken, AgentUtils.USER_AGENT, - LONG_TIMEOUT, LONG_TIMEOUT); - try { - ProcessInformation processInformation = new ProcessInformationRetriever(logger).getProcessInformation(); - Response response = teamscaleClient.registerProfiler(configurationId, - processInformation).execute(); - if (!response.isSuccessful()) { - throw new AgentOptionReceiveException( - "Failed to retrieve profiler configuration from Teamscale due to failed request. Http status: " + response.code() - + " Body: " + response.errorBody().string()); - } - - ResponseBody body = response.body(); - return parseProfilerRegistration(body, response, teamscaleClient, processInformation); - } catch (IOException e) { - // we include the causing error message in this exception's message since this causes it to be printed - // to stderr which is much more helpful than just saying "something didn't work" - throw new AgentOptionReceiveException( - "Failed to retrieve profiler configuration from Teamscale due to network error: " + LoggingUtils.getStackTraceAsString( - e), - e); - } - } - - private static @NotNull ConfigurationViaTeamscale parseProfilerRegistration(ResponseBody body, - Response response, ITeamscaleService teamscaleClient, - ProcessInformation processInformation) throws AgentOptionReceiveException, IOException { - if (body == null) { - throw new AgentOptionReceiveException( - "Failed to retrieve profiler configuration from Teamscale due to empty response. HTTP code: " + response.code()); - } - // We may only call this once - String bodyString = body.string(); - try { - ProfilerRegistration registration = JsonUtils.deserialize(bodyString, - ProfilerRegistration.class); - if (registration == null) { - throw new AgentOptionReceiveException( - "Failed to retrieve profiler configuration from Teamscale due to invalid JSON. HTTP code: " + response.code() + " Response: " + bodyString); - } - return new ConfigurationViaTeamscale(teamscaleClient, registration, processInformation); - } catch (JsonProcessingException e) { - throw new AgentOptionReceiveException( - "Failed to retrieve profiler configuration from Teamscale due to invalid JSON. HTTP code: " + response.code() + " Response: " + bodyString, - e); - } - } - - /** Returns the profiler configuration that was retrieved from Teamscale. */ - public ProfilerConfiguration getProfilerConfiguration() { - return profilerInfo.profilerConfiguration; - } - - - /** - * Starts a heartbeat thread and registers a shutdown hook. - *

- * This spawns a new thread every minute which sends a heartbeat to Teamscale. It also registers a shutdown hook - * that unregisters the profiler from Teamscale. - */ - public void startHeartbeatThreadAndRegisterShutdownHook() { - ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(runnable -> { - Thread thread = new Thread(runnable); - thread.setDaemon(true); - return thread; - }); - - executor.scheduleAtFixedRate(this::sendHeartbeat, 1, 1, TimeUnit.MINUTES); - - Runtime.getRuntime().addShutdownHook(new Thread(() -> { - executor.shutdownNow(); - unregisterProfiler(); - })); - } - - private void sendHeartbeat() { - try { - Response response = teamscaleClient.sendHeartbeat(profilerId, profilerInfo).execute(); - if (!response.isSuccessful()) { - LoggingUtils.getLogger(this) - .error("Failed to send heartbeat. Teamscale responded with: " + response.errorBody().string()); - } - } catch (IOException e) { - LoggingUtils.getLogger(this).error("Failed to send heartbeat to Teamscale!", e); - } - } - - /** Unregisters the profiler in Teamscale (marks it as shut down). */ - public void unregisterProfiler() { - try { - Response response = teamscaleClient.unregisterProfiler(profilerId).execute(); - if (response.code() == 405) { - response = teamscaleClient.unregisterProfilerLegacy(profilerId).execute(); - } - if (!response.isSuccessful()) { - LoggingUtils.getLogger(this) - .error("Failed to unregister profiler. Teamscale responded with: " + response.errorBody() - .string()); - } - } catch (IOException e) { - LoggingUtils.getLogger(this).error("Failed to unregister profiler!", e); - } - } - - public String getProfilerId() { - return profilerId; - } -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/configuration/ProcessInformationRetriever.java b/agent/src/main/java/com/teamscale/jacoco/agent/configuration/ProcessInformationRetriever.java deleted file mode 100644 index 63a34f4cf..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/configuration/ProcessInformationRetriever.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.teamscale.jacoco.agent.configuration; - -import com.teamscale.client.ProcessInformation; -import com.teamscale.report.util.ILogger; - -import java.lang.management.ManagementFactory; -import java.lang.reflect.InvocationTargetException; -import java.net.InetAddress; -import java.net.UnknownHostException; - -/** - * Is responsible for retrieving process information such as the host name and process ID. - */ -public class ProcessInformationRetriever { - - private final ILogger logger; - - public ProcessInformationRetriever(ILogger logger) { - this.logger = logger; - } - - /** - * Retrieves the process information, including the host name and process ID. - */ - public ProcessInformation getProcessInformation() { - String hostName = getHostName(); - String processId = getPID(); - return new ProcessInformation(hostName, processId, System.currentTimeMillis()); - } - - /** - * Retrieves the host name of the local machine. - */ - private String getHostName() { - try { - InetAddress inetAddress = InetAddress.getLocalHost(); - return inetAddress.getHostName(); - } catch (UnknownHostException e) { - logger.error("Failed to determine hostname!", e); - return ""; - } - } - - /** - * Returns a string that probably contains the PID. - *

- * On Java 9 there is an API to get the PID. But since we support Java 8, we may fall back to an undocumented API - * that at least contains the PID in most JVMs. - *

- * See This - * StackOverflow question - */ - public static String getPID() { - try { - Class processHandleClass = Class.forName("java.lang.ProcessHandle"); - Object processHandle = processHandleClass.getMethod("current").invoke(null); - Long pid = (Long) processHandleClass.getMethod("pid").invoke(processHandle); - return pid.toString(); - } catch (ClassNotFoundException | NoSuchMethodException | IllegalAccessException | - InvocationTargetException e) { - return ManagementFactory.getRuntimeMXBean().getName(); - } - } -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/convert/ConvertCommand.java b/agent/src/main/java/com/teamscale/jacoco/agent/convert/ConvertCommand.java deleted file mode 100644 index 116c5fe44..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/convert/ConvertCommand.java +++ /dev/null @@ -1,171 +0,0 @@ -/*-------------------------------------------------------------------------+ -| | -| Copyright (c) 2009-2017 CQSE GmbH | -| | -+-------------------------------------------------------------------------*/ -package com.teamscale.jacoco.agent.convert; - -import com.beust.jcommander.JCommander; -import com.beust.jcommander.Parameter; -import com.beust.jcommander.Parameters; -import com.teamscale.client.FileSystemUtils; -import com.teamscale.client.StringUtils; -import com.teamscale.jacoco.agent.commandline.ICommand; -import com.teamscale.jacoco.agent.commandline.Validator; -import com.teamscale.jacoco.agent.options.ClasspathUtils; -import com.teamscale.jacoco.agent.options.FilePatternResolver; -import com.teamscale.jacoco.agent.util.Assertions; -import com.teamscale.report.EDuplicateClassFileBehavior; -import com.teamscale.report.util.CommandLineLogger; - -import java.io.File; -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; -import java.util.stream.Collectors; - -/** - * Encapsulates all command line options for the convert command for parsing with {@link JCommander}. - */ -@Parameters(commandNames = "convert", commandDescription = "Converts a binary .exec coverage file to XML. " + - "Note that the XML report will only contain source file coverage information, but no class coverage.") -public class ConvertCommand implements ICommand { - - /** The directories and/or zips that contain all class files being profiled. */ - @Parameter(names = {"--class-dir", "--jar", "-c"}, required = true, description = "" - + "The directories or zip/ear/jar/war/... files that contain the compiled Java classes being profiled." - + " Searches recursively, including inside zips. You may also supply a *.txt file with one path per line.") - /* package */ List classDirectoriesOrZips = new ArrayList<>(); - - /** - * Wildcard include patterns to apply during JaCoCo's traversal of class files. - */ - @Parameter(names = {"--includes"}, description = "" - + "Wildcard include patterns to apply to all found class file locations during JaCoCo's traversal of class files." - + " Note that zip contents are separated from zip files with @ and that you can filter only" - + " class files, not intermediate folders/zips. Use with great care as missing class files" - + " lead to broken coverage files! Turn on debug logging to see which locations are being filtered." - + " Defaults to no filtering. Excludes overrule includes.") - /* package */ List locationIncludeFilters = new ArrayList<>(); - - /** - * Wildcard exclude patterns to apply during JaCoCo's traversal of class files. - */ - @Parameter(names = {"--excludes", "-e"}, description = "" - + "Wildcard exclude patterns to apply to all found class file locations during JaCoCo's traversal of class files." - + " Note that zip contents are separated from zip files with @ and that you can filter only" - + " class files, not intermediate folders/zips. Use with great care as missing class files" - + " lead to broken coverage files! Turn on debug logging to see which locations are being filtered." - + " Defaults to no filtering. Excludes overrule includes.") - /* package */ List locationExcludeFilters = new ArrayList<>(); - - /** The directory to write the XML traces to. */ - @Parameter(names = {"--in", "-i"}, required = true, description = "" + "The binary .exec file(s), test details and " + - "test executions to read. Can be a single file or a directory that is recursively scanned for relevant files.") - /* package */ List inputFiles = new ArrayList<>(); - - /** The directory to write the XML traces to. */ - @Parameter(names = {"--out", "-o"}, required = true, description = "" - + "The file to write the generated XML report to.") - /* package */ String outputFile = ""; - - /** Whether to ignore duplicate, non-identical class files. */ - @Parameter(names = {"--duplicates", "-d"}, arity = 1, description = "" - + "Whether to ignore duplicate, non-identical class files." - + " This is discouraged and may result in incorrect coverage files. Defaults to WARN. " + - "Options are FAIL, WARN and IGNORE.") - /* package */ EDuplicateClassFileBehavior duplicateClassFileBehavior = EDuplicateClassFileBehavior.WARN; - - /** Whether to ignore uncovered class files. */ - @Parameter(names = {"--ignore-uncovered-classes"}, required = false, arity = 1, description = "" - + "Whether to ignore uncovered classes." - + " These classes will not be part of the XML report at all, making it considerably smaller in some cases. Defaults to false.") - /* package */ boolean shouldIgnoreUncoveredClasses = false; - - /** Whether testwise coverage or jacoco coverage should be generated. */ - @Parameter(names = {"--testwise-coverage", "-t"}, required = false, arity = 0, description = "Whether testwise " + - "coverage or jacoco coverage should be generated.") - /* package */ boolean shouldGenerateTestwiseCoverage = false; - - /** After how many tests testwise coverage should be split into multiple reports. */ - @Parameter(names = {"--split-after", "-s"}, required = false, arity = 1, description = "After how many tests " + - "testwise coverage should be split into multiple reports (Default is 5000).") - private int splitAfter = 5000; - - /** @see #classDirectoriesOrZips */ - public List getClassDirectoriesOrZips() throws IOException { - return ClasspathUtils - .resolveClasspathTextFiles("class-dir", new FilePatternResolver(new CommandLineLogger()), - classDirectoriesOrZips); - } - - /** @see #locationIncludeFilters */ - public List getLocationIncludeFilters() { - return locationIncludeFilters; - } - - /** @see #locationExcludeFilters */ - public List getLocationExcludeFilters() { - return locationExcludeFilters; - } - - /** @see #inputFiles */ - public List getInputFiles() { - return inputFiles.stream().map(File::new).collect(Collectors.toList()); - } - - /** @see #outputFile */ - public File getOutputFile() { - return new File(outputFile); - } - - /** @see #splitAfter */ - public int getSplitAfter() { - return splitAfter; - } - - /** @see #duplicateClassFileBehavior */ - public EDuplicateClassFileBehavior getDuplicateClassFileBehavior() { - return duplicateClassFileBehavior; - } - - /** Makes sure the arguments are valid. */ - @Override - public Validator validate() { - Validator validator = new Validator(); - - List classDirectoriesOrZips = new ArrayList<>(); - validator.ensure(() -> classDirectoriesOrZips.addAll(getClassDirectoriesOrZips())); - validator.isFalse(classDirectoriesOrZips.isEmpty(), - "You must specify at least one directory or zip that contains class files"); - for (File path : classDirectoriesOrZips) { - validator.isTrue(path.exists(), "Path '" + path + "' does not exist"); - validator.isTrue(path.canRead(), "Path '" + path + "' is not readable"); - } - - for (File inputFile : getInputFiles()) { - validator.isTrue(inputFile.exists() && inputFile.canRead(), - "Cannot read the input file " + inputFile); - } - - validator.ensure(() -> { - Assertions.isFalse(StringUtils.isEmpty(outputFile), "You must specify an output file"); - File outputDir = getOutputFile().getAbsoluteFile().getParentFile(); - FileSystemUtils.ensureDirectoryExists(outputDir); - Assertions.isTrue(outputDir.canWrite(), "Path '" + outputDir + "' is not writable"); - }); - - return validator; - } - - /** {@inheritDoc} */ - @Override - public void run() throws Exception { - Converter converter = new Converter(this); - if (this.shouldGenerateTestwiseCoverage) { - converter.runTestwiseCoverageReportGeneration(); - } else { - converter.runJaCoCoReportGeneration(); - } - } -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/convert/Converter.java b/agent/src/main/java/com/teamscale/jacoco/agent/convert/Converter.java deleted file mode 100644 index e46cc5852..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/convert/Converter.java +++ /dev/null @@ -1,95 +0,0 @@ -package com.teamscale.jacoco.agent.convert; - -import com.teamscale.client.TestDetails; -import com.teamscale.jacoco.agent.logging.LoggingUtils; -import com.teamscale.jacoco.agent.options.AgentOptionParseException; -import com.teamscale.jacoco.agent.util.Benchmark; -import com.teamscale.report.ReportUtils; -import com.teamscale.report.jacoco.EmptyReportException; -import com.teamscale.report.jacoco.JaCoCoXmlReportGenerator; -import com.teamscale.report.testwise.ETestArtifactFormat; -import com.teamscale.report.testwise.TestwiseCoverageReportWriter; -import com.teamscale.report.testwise.jacoco.JaCoCoTestwiseReportGenerator; -import com.teamscale.report.testwise.model.TestExecution; -import com.teamscale.report.testwise.model.factory.TestInfoFactory; -import com.teamscale.report.util.ClasspathWildcardIncludeFilter; -import com.teamscale.report.util.CommandLineLogger; -import com.teamscale.report.util.ILogger; -import org.slf4j.Logger; - -import java.io.File; -import java.io.IOException; -import java.nio.file.Paths; -import java.util.List; - -import static com.teamscale.jacoco.agent.logging.LoggingUtils.wrap; - -/** Converts one .exec binary coverage file to XML. */ -public class Converter { - - /** The command line arguments. */ - private ConvertCommand arguments; - - /** Constructor. */ - public Converter(ConvertCommand arguments) { - this.arguments = arguments; - } - - /** Converts one .exec binary coverage file to XML. */ - public void runJaCoCoReportGeneration() throws IOException { - List jacocoExecutionDataList = ReportUtils - .listFiles(ETestArtifactFormat.JACOCO, arguments.getInputFiles()); - - Logger logger = LoggingUtils.getLogger(this); - JaCoCoXmlReportGenerator generator = new JaCoCoXmlReportGenerator(arguments.getClassDirectoriesOrZips(), - getWildcardIncludeExcludeFilter(), arguments.getDuplicateClassFileBehavior(), - arguments.shouldIgnoreUncoveredClasses, - wrap(logger)); - - try (Benchmark benchmark = new Benchmark("Generating the XML report")) { - generator.convertExecFilesToReport(jacocoExecutionDataList, Paths.get(arguments.outputFile).toFile()); - } catch (EmptyReportException e) { - logger.warn("Converted report was empty.", e); - } - } - - /** Converts one .exec binary coverage file, test details and test execution files to JSON testwise coverage. */ - public void runTestwiseCoverageReportGeneration() throws IOException, AgentOptionParseException { - List testDetails = ReportUtils.readObjects(ETestArtifactFormat.TEST_LIST, - TestDetails[].class, arguments.getInputFiles()); - List testExecutions = ReportUtils.readObjects(ETestArtifactFormat.TEST_EXECUTION, - TestExecution[].class, arguments.getInputFiles()); - - List jacocoExecutionDataList = ReportUtils - .listFiles(ETestArtifactFormat.JACOCO, arguments.getInputFiles()); - ILogger logger = new CommandLineLogger(); - - JaCoCoTestwiseReportGenerator generator = new JaCoCoTestwiseReportGenerator( - arguments.getClassDirectoriesOrZips(), - getWildcardIncludeExcludeFilter(), - arguments.getDuplicateClassFileBehavior(), - logger - ); - - TestInfoFactory testInfoFactory = new TestInfoFactory(testDetails, testExecutions); - - try (Benchmark benchmark = new Benchmark("Generating the testwise coverage report")) { - logger.info( - "Writing report with " + testDetails.size() + " Details/" + testExecutions.size() + " Results"); - - try (TestwiseCoverageReportWriter coverageWriter = new TestwiseCoverageReportWriter(testInfoFactory, - arguments.getOutputFile(), arguments.getSplitAfter(), null)) { - for (File executionDataFile : jacocoExecutionDataList) { - generator.convertAndConsume(executionDataFile, coverageWriter); - } - } - } - } - - private ClasspathWildcardIncludeFilter getWildcardIncludeExcludeFilter() { - return new ClasspathWildcardIncludeFilter( - String.join(":", arguments.getLocationIncludeFilters()), - String.join(":", arguments.getLocationExcludeFilters())); - } - -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/logging/DebugLogDirectoryPropertyDefiner.java b/agent/src/main/java/com/teamscale/jacoco/agent/logging/DebugLogDirectoryPropertyDefiner.java deleted file mode 100644 index ce83d7422..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/logging/DebugLogDirectoryPropertyDefiner.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.teamscale.jacoco.agent.logging; - -import java.nio.file.Path; - -/** Defines a property that contains the path to which log files should be written. */ -public class DebugLogDirectoryPropertyDefiner extends LogDirectoryPropertyDefiner { - - /** File path for debug logging. */ - /* package */ static Path filePath = null; - - @Override - public String getPropertyValue() { - if (filePath == null) { - return super.getPropertyValue(); - } - return filePath.resolve("logs").toAbsolutePath().toString(); - } -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/logging/LogDirectoryPropertyDefiner.java b/agent/src/main/java/com/teamscale/jacoco/agent/logging/LogDirectoryPropertyDefiner.java deleted file mode 100644 index a10c57221..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/logging/LogDirectoryPropertyDefiner.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.teamscale.jacoco.agent.logging; - -import ch.qos.logback.core.PropertyDefinerBase; -import com.teamscale.jacoco.agent.util.AgentUtils; - -import java.nio.file.Path; - -/** Defines a property that contains the default path to which log files should be written. */ -public class LogDirectoryPropertyDefiner extends PropertyDefinerBase { - @Override - public String getPropertyValue() { - Path tempDirectory = AgentUtils.getMainTempDirectory(); - return tempDirectory.resolve("logs").toAbsolutePath().toString(); - } -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/logging/LogToTeamscaleAppender.java b/agent/src/main/java/com/teamscale/jacoco/agent/logging/LogToTeamscaleAppender.java deleted file mode 100644 index 4a1906388..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/logging/LogToTeamscaleAppender.java +++ /dev/null @@ -1,207 +0,0 @@ -package com.teamscale.jacoco.agent.logging; - -import ch.qos.logback.classic.Logger; -import ch.qos.logback.classic.LoggerContext; -import ch.qos.logback.classic.spi.ILoggingEvent; -import ch.qos.logback.core.AppenderBase; -import ch.qos.logback.core.status.ErrorStatus; -import com.teamscale.client.ITeamscaleService; -import com.teamscale.client.ProfilerLogEntry; -import com.teamscale.client.TeamscaleClient; -import com.teamscale.jacoco.agent.options.AgentOptions; -import org.jetbrains.annotations.Nullable; -import retrofit2.Call; - -import java.net.ConnectException; -import java.time.Duration; -import java.util.ArrayList; -import java.util.Collections; -import java.util.IdentityHashMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Set; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; - -import static com.teamscale.jacoco.agent.logging.LoggingUtils.getStackTraceFromEvent; - -/** - * Custom log appender that sends logs to Teamscale; it buffers log that were not sent due to connection issues and - * sends them later. - */ -public class LogToTeamscaleAppender extends AppenderBase { - - /** Flush the logs after N elements are in the queue */ - private static final int BATCH_SIZE = 50; - - /** Flush the logs in the given time interval */ - private static final Duration FLUSH_INTERVAL = Duration.ofSeconds(3); - - /** The unique ID of the profiler */ - private String profilerId; - - /** The service client for sending logs to Teamscale */ - private static ITeamscaleService teamscaleClient; - - /** - * Buffer for unsent logs. We use a set here to allow for removing entries fast after sending them to Teamscale was - * successful. - */ - private final LinkedHashSet logBuffer = new LinkedHashSet<>(); - - /** Scheduler for sending logs after the configured time interval */ - private final ScheduledExecutorService scheduler; - - /** Active log flushing threads */ - private final Set> activeLogFlushes = Collections.newSetFromMap(new IdentityHashMap<>()); - - /** Is there a flush going on right now? */ - private final AtomicBoolean isFlusing = new AtomicBoolean(false); - - public LogToTeamscaleAppender() { - this.scheduler = Executors.newScheduledThreadPool(1, r -> { - // Make the thread a daemon so that it does not prevent the JVM from terminating. - Thread t = Executors.defaultThreadFactory().newThread(r); - t.setDaemon(true); - return t; - }); - } - - @Override - public void start() { - super.start(); - scheduler.scheduleAtFixedRate(() -> { - synchronized (activeLogFlushes) { - activeLogFlushes.removeIf(CompletableFuture::isDone); - if (this.activeLogFlushes.isEmpty()) { - flush(); - } - } - }, FLUSH_INTERVAL.toMillis(), FLUSH_INTERVAL.toMillis(), TimeUnit.MILLISECONDS); - } - - @Override - protected void append(ILoggingEvent eventObject) { - synchronized (logBuffer) { - logBuffer.add(formatLog(eventObject)); - if (logBuffer.size() >= BATCH_SIZE) { - flush(); - } - } - } - - private ProfilerLogEntry formatLog(ILoggingEvent eventObject) { - String trace = getStackTraceFromEvent(eventObject); - long timestamp = eventObject.getTimeStamp(); - String message = eventObject.getFormattedMessage(); - String severity = eventObject.getLevel().toString(); - return new ProfilerLogEntry(timestamp, message, trace, severity); - } - - private void flush() { - sendLogs(); - } - - /** Send logs in a separate thread */ - private void sendLogs() { - synchronized (activeLogFlushes) { - activeLogFlushes.add(CompletableFuture.runAsync(() -> { - if (isFlusing.compareAndSet(false, true)) { - try { - if (teamscaleClient == null) { - // There might be no connection configured. - return; - } - - List logsToSend; - synchronized (logBuffer) { - logsToSend = new ArrayList<>(logBuffer); - } - - Call call = teamscaleClient.postProfilerLog(profilerId, logsToSend); - retrofit2.Response response = call.execute(); - if (!response.isSuccessful()) { - throw new IllegalStateException("Failed to send log: HTTP error code : " + response.code()); - } - - synchronized (logBuffer) { - // Removing the logs that have been sent after the fact. - // This handles problems with lost network connections. - logsToSend.forEach(logBuffer::remove); - } - } catch (Exception e) { - // We do not report on exceptions here. - if (!(e instanceof ConnectException)) { - addStatus(new ErrorStatus("Sending logs to Teamscale failed: " + e.getMessage(), this, e)); - } - } finally { - isFlusing.set(false); - } - } - }).whenComplete((result, throwable) -> { - synchronized (activeLogFlushes) { - activeLogFlushes.removeIf(CompletableFuture::isDone); - } - })); - } - } - - @Override - public void stop() { - // Already flush here once to make sure that we do not miss too much. - flush(); - - scheduler.shutdown(); - try { - if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) { - scheduler.shutdownNow(); - } - } catch (InterruptedException e) { - scheduler.shutdownNow(); - } - - // A final flush after the scheduler has been shut down. - flush(); - - // Block until all flushes are done - CompletableFuture.allOf(activeLogFlushes.toArray(new CompletableFuture[0])).join(); - - super.stop(); - } - - public void setTeamscaleClient(ITeamscaleService teamscaleClient) { - this.teamscaleClient = teamscaleClient; - } - - public void setProfilerId(String profilerId) { - this.profilerId = profilerId; - } - - /** - * Add the {@link com.teamscale.jacoco.agent.logging.LogToTeamscaleAppender} to the logging configuration and - * enable/start it. - */ - public static boolean addTeamscaleAppenderTo(LoggerContext context, AgentOptions agentOptions) { - @Nullable TeamscaleClient client = agentOptions.createTeamscaleClient( - false); - if (client == null || agentOptions.configurationViaTeamscale == null) { - return false; - } - - ITeamscaleService serviceClient = client.getService(); - LogToTeamscaleAppender logToTeamscaleAppender = new LogToTeamscaleAppender(); - logToTeamscaleAppender.setContext(context); - logToTeamscaleAppender.setProfilerId(agentOptions.configurationViaTeamscale.getProfilerId()); - logToTeamscaleAppender.setTeamscaleClient(serviceClient); - logToTeamscaleAppender.start(); - - Logger rootLogger = context.getLogger(Logger.ROOT_LOGGER_NAME); - rootLogger.addAppender(logToTeamscaleAppender); - - return true; - } - -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/logging/LoggingUtils.java b/agent/src/main/java/com/teamscale/jacoco/agent/logging/LoggingUtils.java deleted file mode 100644 index d836d8e6f..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/logging/LoggingUtils.java +++ /dev/null @@ -1,172 +0,0 @@ -/*-------------------------------------------------------------------------+ -| | -| Copyright (c) 2009-2018 CQSE GmbH | -| | -+-------------------------------------------------------------------------*/ -package com.teamscale.jacoco.agent.logging; - -import ch.qos.logback.classic.LoggerContext; -import ch.qos.logback.classic.joran.JoranConfigurator; -import ch.qos.logback.classic.spi.ILoggingEvent; -import ch.qos.logback.classic.spi.IThrowableProxy; -import ch.qos.logback.classic.spi.ThrowableProxy; -import ch.qos.logback.classic.spi.ThrowableProxyUtil; -import ch.qos.logback.core.joran.spi.JoranException; -import ch.qos.logback.core.util.StatusPrinter; -import com.teamscale.jacoco.agent.Agent; -import com.teamscale.jacoco.agent.util.NullOutputStream; -import com.teamscale.report.util.ILogger; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.FileInputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.PrintStream; -import java.nio.file.Path; - -/** - * Helps initialize the logging framework properly. - */ -public class LoggingUtils { - - /** Returns a logger for the given object's class. */ - public static Logger getLogger(Object object) { - return LoggerFactory.getLogger(object.getClass()); - } - - /** Returns a logger for the given class. */ - public static Logger getLogger(Class object) { - return LoggerFactory.getLogger(object); - } - - /** Class to use with try-with-resources to close the logging framework's resources. */ - public static class LoggingResources implements AutoCloseable { - - @Override - public void close() { - getLoggerContext().stop(); - } - } - - /** Initializes the logging to the default configured in the Jar. */ - public static LoggingResources initializeDefaultLogging() { - InputStream stream = Agent.class.getResourceAsStream("logback-default.xml"); - reconfigureLoggerContext(stream); - return new LoggingResources(); - } - - /** - * Returns the logger context. - */ - public static LoggerContext getLoggerContext() { - return (LoggerContext) LoggerFactory.getILoggerFactory(); - } - - /** - * Extracts the stack trace from an ILoggingEvent using ThrowableProxyUtil. - * - * @param event the logging event containing the exception - * @return the stack trace as a String, or null if no exception is associated - */ - public static String getStackTraceFromEvent(ILoggingEvent event) { - IThrowableProxy throwableProxy = event.getThrowableProxy(); - - if (throwableProxy != null) { - // Use ThrowableProxyUtil to convert the IThrowableProxy to a String - return ThrowableProxyUtil.asString(throwableProxy); - } - - return null; - } - - /** - * Converts a Throwable to its stack trace as a String. - * - * @param throwable the throwable to convert - * @return the stack trace as a String - */ - public static String getStackTraceAsString(Throwable throwable) { - if (throwable == null) { - return null; - } - return ThrowableProxyUtil.asString(new ThrowableProxy(throwable)); - } - - /** - * Reconfigures the logger context to use the configuration XML from the given input stream. Cf. https://logback.qos.ch/manual/configuration.html - */ - private static void reconfigureLoggerContext(InputStream stream) { - StatusPrinter.setPrintStream(new PrintStream(new NullOutputStream())); - LoggerContext loggerContext = getLoggerContext(); - try { - JoranConfigurator configurator = new JoranConfigurator(); - configurator.setContext(loggerContext); - loggerContext.reset(); - configurator.doConfigure(stream); - } catch (JoranException je) { - // StatusPrinter will handle this - } - StatusPrinter.printInCaseOfErrorsOrWarnings(loggerContext); - } - - /** - * Initializes the logging from the given file. If that is null, uses {@link - * #initializeDefaultLogging()} instead. - */ - public static LoggingResources initializeLogging(Path loggingConfigFile) throws IOException { - if (loggingConfigFile == null) { - return initializeDefaultLogging(); - } - - reconfigureLoggerContext(new FileInputStream(loggingConfigFile.toFile())); - return new LoggingResources(); - } - - /** Initializes debug logging. */ - public static LoggingResources initializeDebugLogging(Path logDirectory) { - if (logDirectory != null) { - DebugLogDirectoryPropertyDefiner.filePath = logDirectory; - } - InputStream stream = Agent.class.getResourceAsStream("logback-default-debugging.xml"); - reconfigureLoggerContext(stream); - return new LoggingResources(); - } - - /** Wraps the given slf4j logger into an {@link ILogger}. */ - public static ILogger wrap(Logger logger) { - return new ILogger() { - @Override - public void debug(String message) { - logger.debug(message); - } - - @Override - public void info(String message) { - logger.info(message); - } - - @Override - public void warn(String message) { - logger.warn(message); - } - - @Override - public void warn(String message, Throwable throwable) { - logger.warn(message, throwable); - } - - @Override - public void error(Throwable throwable) { - logger.error(throwable.getMessage(), throwable); - } - - @Override - public void error(String message, Throwable throwable) { - logger.error(message, throwable); - } - }; - } - -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/options/AgentOptions.java b/agent/src/main/java/com/teamscale/jacoco/agent/options/AgentOptions.java index 4a19f3e3f..c34068750 100644 --- a/agent/src/main/java/com/teamscale/jacoco/agent/options/AgentOptions.java +++ b/agent/src/main/java/com/teamscale/jacoco/agent/options/AgentOptions.java @@ -559,17 +559,17 @@ private void registerSingleGitPropertiesLocator(DelayedUploader createDelayedSingleProjectTeamscaleUploader() { return new DelayedUploader<>( projectAndCommit -> { - if (!StringUtils.isEmpty(projectAndCommit.getProject()) && !teamscaleServer.project - .equals(projectAndCommit.getProject())) { + if (!StringUtils.isEmpty(projectAndCommit.project) && !teamscaleServer.project + .equals(projectAndCommit.project)) { logger.warn( - "Teamscale project '" + teamscaleServer.project + "' specified in the agent configuration is not the same as the Teamscale project '" + projectAndCommit.getProject() + "' specified in git.properties file(s). Proceeding to upload to the" + + "Teamscale project '" + teamscaleServer.project + "' specified in the agent configuration is not the same as the Teamscale project '" + projectAndCommit.project + "' specified in git.properties file(s). Proceeding to upload to the" + " Teamscale project '" + teamscaleServer.project + "' specified in the agent configuration."); } - if (projectAndCommit.getCommitInfo().preferCommitDescriptorOverRevision || - StringUtils.isEmpty(projectAndCommit.getCommitInfo().revision)) { - teamscaleServer.commit = projectAndCommit.getCommitInfo().commit; + if (projectAndCommit.commitInfo.preferCommitDescriptorOverRevision || + StringUtils.isEmpty(projectAndCommit.commitInfo.revision)) { + teamscaleServer.commit = projectAndCommit.commitInfo.commit; } else { - teamscaleServer.revision = projectAndCommit.getCommitInfo().revision; + teamscaleServer.revision = projectAndCommit.commitInfo.revision; } return new TeamscaleUploader(teamscaleServer); }, outputDirectory); diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/options/ProjectAndCommit.java b/agent/src/main/java/com/teamscale/jacoco/agent/options/ProjectAndCommit.java deleted file mode 100644 index 58e9d104c..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/options/ProjectAndCommit.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.teamscale.jacoco.agent.options; - -import com.teamscale.jacoco.agent.commit_resolution.git_properties.CommitInfo; - -import java.util.Objects; - -/** Class encapsulating the Teamscale project and git commitInfo an upload should be performed to. */ -public class ProjectAndCommit { - - private final String project; - private final CommitInfo commitInfo; - - public ProjectAndCommit(String project, CommitInfo commitInfo) { - this.project = project; - this.commitInfo = commitInfo; - } - - /** @see #project */ - public String getProject() { - return project; - } - - /** @see #commitInfo */ - public CommitInfo getCommitInfo() { - return commitInfo; - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - ProjectAndCommit that = (ProjectAndCommit) o; - return Objects.equals(project, that.project) && - Objects.equals(commitInfo, that.commitInfo); - } - - @Override - public int hashCode() { - return Objects.hash(project, commitInfo); - } - - @Override - public String toString() { - return "ProjectRevision{" + - "project='" + project + '\'' + - ", commitInfo='" + commitInfo + '\'' + - '}'; - } -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/upload/DelayedMultiUploaderBase.java b/agent/src/main/java/com/teamscale/jacoco/agent/upload/DelayedMultiUploaderBase.java deleted file mode 100644 index 2e76f9a9f..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/upload/DelayedMultiUploaderBase.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.teamscale.jacoco.agent.upload; - -import java.util.Collection; -import java.util.stream.Collectors; - -import org.slf4j.Logger; - -import com.teamscale.jacoco.agent.logging.LoggingUtils; -import com.teamscale.report.jacoco.CoverageFile; - -/** - * Base class for wrapper uploaders that allow uploading the same coverage to - * multiple locations. - */ -public abstract class DelayedMultiUploaderBase implements IUploader { - - /** Logger. */ - protected final Logger logger = LoggingUtils.getLogger(this); - - @Override - public synchronized void upload(CoverageFile file) { - Collection wrappedUploaders = getWrappedUploaders(); - wrappedUploaders.forEach(uploader -> file.acquireReference()); - if (wrappedUploaders.isEmpty()) { - logger.warn("No commits have been found yet to which coverage should be uploaded. Discarding coverage"); - } else { - for (IUploader wrappedUploader : wrappedUploaders) { - wrappedUploader.upload(file); - } - } - } - - @Override - public String describe() { - Collection wrappedUploaders = getWrappedUploaders(); - if (!wrappedUploaders.isEmpty()) { - return wrappedUploaders.stream().map(IUploader::describe).collect(Collectors.joining(", ")); - } - return "Temporary stand-in until commit is resolved"; - } - - /** Returns the actual uploaders that this multiuploader wraps. */ - protected abstract Collection getWrappedUploaders(); -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/upload/HttpZipUploaderBase.java b/agent/src/main/java/com/teamscale/jacoco/agent/upload/HttpZipUploaderBase.java deleted file mode 100644 index 3d36723cd..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/upload/HttpZipUploaderBase.java +++ /dev/null @@ -1,153 +0,0 @@ -package com.teamscale.jacoco.agent.upload; - -import com.teamscale.client.FileSystemUtils; -import com.teamscale.client.HttpUtils; -import com.teamscale.jacoco.agent.logging.LoggingUtils; -import com.teamscale.jacoco.agent.util.Benchmark; -import com.teamscale.report.jacoco.CoverageFile; -import okhttp3.HttpUrl; -import okhttp3.OkHttpClient; -import okhttp3.ResponseBody; -import org.slf4j.Logger; -import retrofit2.Response; -import retrofit2.Retrofit; - -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import java.util.zip.ZipEntry; -import java.util.zip.ZipOutputStream; - -/** Base class for uploading the coverage zip to a provided url */ -public abstract class HttpZipUploaderBase implements IUploader { - - /** The logger. */ - protected final Logger logger = LoggingUtils.getLogger(this); - - /** The URL to upload to. */ - protected HttpUrl uploadUrl; - - /** Additional files to include in the uploaded zip. */ - protected final List additionalMetaDataFiles; - - /** The API class. */ - private final Class apiClass; - - /** The API which performs the upload */ - private T api; - - /** Constructor. */ - public HttpZipUploaderBase(HttpUrl uploadUrl, List additionalMetaDataFiles, Class apiClass) { - this.uploadUrl = uploadUrl; - this.additionalMetaDataFiles = additionalMetaDataFiles; - this.apiClass = apiClass; - } - - /** Template method to configure the OkHttp Client. */ - protected void configureOkHttp(OkHttpClient.Builder builder) { - } - - /** Returns the API for creating request to the http uploader */ - protected T getApi() { - if (api == null) { - Retrofit retrofit = HttpUtils.createRetrofit(retrofitBuilder -> retrofitBuilder.baseUrl(uploadUrl), - this::configureOkHttp); - api = retrofit.create(apiClass); - } - - return api; - } - - /** Uploads the coverage zip to the server */ - protected abstract Response uploadCoverageZip(File coverageFile) - throws IOException, UploaderException; - - @Override - public void upload(CoverageFile coverageFile) { - try (Benchmark ignored = new Benchmark("Uploading report via HTTP")) { - if (tryUpload(coverageFile)) { - coverageFile.delete(); - } else { - logger.warn("Failed to upload coverage to Teamscale. " - + "Won't delete local file {} so that the upload can automatically be retried upon profiler restart. " - + "Upload can also be retried manually.", coverageFile); - if (this instanceof IUploadRetry) { - ((IUploadRetry) this).markFileForUploadRetry(coverageFile); - } - } - } catch (IOException e) { - logger.warn("Could not delete file {} after upload", coverageFile); - } - } - - /** Performs the upload and returns true if successful. */ - protected boolean tryUpload(CoverageFile coverageFile) { - logger.debug("Uploading coverage to {}", uploadUrl); - - File zipFile; - try { - zipFile = createZipFile(coverageFile); - } catch (IOException e) { - logger.error("Failed to compile coverage zip file for upload to {}", uploadUrl, e); - return false; - } - - try { - Response response = uploadCoverageZip(zipFile); - if (response.isSuccessful()) { - return true; - } - - String errorBody = ""; - if (response.errorBody() != null) { - errorBody = response.errorBody().string(); - } - - logger.error("Failed to upload coverage to {}. Request failed with error code {}. Error:\n{}", uploadUrl, - response.code(), errorBody); - return false; - } catch (IOException e) { - logger.error("Failed to upload coverage to {}. Probably a network problem", uploadUrl, e); - return false; - } catch (UploaderException e) { - logger.error("Failed to upload coverage to {}. The configuration is probably incorrect", uploadUrl, e); - return false; - } finally { - zipFile.delete(); - } - } - - /** - * Creates the zip file in the system temp directory to upload which includes the given coverage XML and all - * {@link #additionalMetaDataFiles}. The file is marked to be deleted on exit. - */ - private File createZipFile(CoverageFile coverageFile) throws IOException { - File zipFile = Files.createTempFile(coverageFile.getNameWithoutExtension(), ".zip").toFile(); - zipFile.deleteOnExit(); - try (FileOutputStream fileOutputStream = new FileOutputStream(zipFile); - ZipOutputStream zipOutputStream = new ZipOutputStream(fileOutputStream)) { - fillZipFile(zipOutputStream, coverageFile); - return zipFile; - } - } - - /** - * Fills the upload zip file with the given coverage XML and all {@link #additionalMetaDataFiles}. - */ - private void fillZipFile(ZipOutputStream zipOutputStream, CoverageFile coverageFile) throws IOException { - zipOutputStream.putNextEntry(new ZipEntry(getZipEntryCoverageFileName(coverageFile))); - coverageFile.copyStream(zipOutputStream); - - for (Path additionalFile : additionalMetaDataFiles) { - zipOutputStream.putNextEntry(new ZipEntry(additionalFile.getFileName().toString())); - zipOutputStream.write(FileSystemUtils.readFileBinary(additionalFile.toFile())); - } - } - - protected String getZipEntryCoverageFileName(CoverageFile coverageFile) { - return "coverage.xml"; - } -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/upload/IUploader.java b/agent/src/main/java/com/teamscale/jacoco/agent/upload/IUploader.java deleted file mode 100644 index 0cc1fdcb1..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/upload/IUploader.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.teamscale.jacoco.agent.upload; - -import com.teamscale.report.jacoco.CoverageFile; - -/** Uploads coverage reports. */ -public interface IUploader { - - /** - * Uploads the given coverage file. If the upload was successful, the coverage - * file on disk will be deleted. Otherwise the file is left on disk and a - * warning is logged. - */ - void upload(CoverageFile coverageFile); - - /** Human-readable description of the uploader. */ - String describe(); - -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/upload/LocalDiskUploader.java b/agent/src/main/java/com/teamscale/jacoco/agent/upload/LocalDiskUploader.java deleted file mode 100644 index 67c01f3ae..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/upload/LocalDiskUploader.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.teamscale.jacoco.agent.upload; - -import com.teamscale.report.jacoco.CoverageFile; - -/** - * Dummy uploader which keeps the coverage file written by the agent on disk, - * but does not actually perform uploads. - */ -public class LocalDiskUploader implements IUploader { - @Override - public void upload(CoverageFile coverageFile) { - // Don't delete the file here. We want to store the file permanently on disk in - // case no uploader is configured. - } - - @Override - public String describe() { - return "configured output directory on the local disk"; - } - -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/upload/UploaderException.java b/agent/src/main/java/com/teamscale/jacoco/agent/upload/UploaderException.java deleted file mode 100644 index a022c25bd..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/upload/UploaderException.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.teamscale.jacoco.agent.upload; - -import okhttp3.ResponseBody; -import retrofit2.Response; - -import java.io.IOException; - -/** - * Exception thrown from an uploader. Either during the upload or in the validation process. - */ -public class UploaderException extends Exception { - - /** Constructor */ - public UploaderException(String message, Exception e) { - super(message, e); - } - - /** Constructor */ - public UploaderException(String message) { - super(message); - } - - /** Constructor */ - public UploaderException(String message, Response response) { - super(createResponseMessage(message, response)); - } - - private static String createResponseMessage(String message, Response response) { - try { - String errorBodyMessage = response.errorBody().string(); - return String.format("%s (%s): \n%s", message, response.code(), errorBodyMessage); - } catch (IOException | NullPointerException e) { - return message; - } - } -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/upload/artifactory/ArtifactoryConfig.java b/agent/src/main/java/com/teamscale/jacoco/agent/upload/artifactory/ArtifactoryConfig.java deleted file mode 100644 index fceead42b..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/upload/artifactory/ArtifactoryConfig.java +++ /dev/null @@ -1,177 +0,0 @@ -package com.teamscale.jacoco.agent.upload.artifactory; - -import com.teamscale.client.StringUtils; -import com.teamscale.jacoco.agent.commit_resolution.git_properties.CommitInfo; -import com.teamscale.jacoco.agent.commit_resolution.git_properties.GitPropertiesLocatorUtils; -import com.teamscale.jacoco.agent.commit_resolution.git_properties.InvalidGitPropertiesException; -import com.teamscale.jacoco.agent.options.AgentOptionParseException; -import com.teamscale.jacoco.agent.options.AgentOptionsParser; -import com.teamscale.jacoco.agent.upload.UploaderException; -import okhttp3.HttpUrl; -import org.jetbrains.annotations.Nullable; - -import java.io.File; -import java.io.IOException; -import java.time.format.DateTimeFormatter; -import java.util.List; - -/** Config necessary to upload files to an azure file storage. */ -public class ArtifactoryConfig { - /** - * Option to specify the artifactory URL. This shall be the entire path down to the directory to which the coverage - * should be uploaded to, not only the base url of artifactory. - */ - public static final String ARTIFACTORY_URL_OPTION = "artifactory-url"; - - /** - * Username that shall be used for basic auth. Alternative to basic auth is to use an API key with the - * {@link ArtifactoryConfig#ARTIFACTORY_API_KEY_OPTION} - */ - public static final String ARTIFACTORY_USER_OPTION = "artifactory-user"; - - /** - * Password that shall be used for basic auth. Alternative to basic auth is to use an API key with the - * {@link ArtifactoryConfig#ARTIFACTORY_API_KEY_OPTION} - */ - public static final String ARTIFACTORY_PASSWORD_OPTION = "artifactory-password"; - - /** - * API key that shall be used to authenticate requests to artifactory with the - * {@link com.teamscale.jacoco.agent.upload.artifactory.ArtifactoryUploader#ARTIFACTORY_API_HEADER}. Alternatively - * basic auth with username ({@link ArtifactoryConfig#ARTIFACTORY_USER_OPTION}) and password - * ({@link ArtifactoryConfig#ARTIFACTORY_PASSWORD_OPTION}) can be used. - */ - public static final String ARTIFACTORY_API_KEY_OPTION = "artifactory-api-key"; - - /** - * Option that specifies if the legacy path for uploading files to artifactory should be used instead of the new - * standard path. - */ - public static final String ARTIFACTORY_LEGACY_PATH_OPTION = "artifactory-legacy-path"; - - /** - * Option that specifies under which path the coverage file shall lie within the zip file that is created for the - * upload. - */ - public static final String ARTIFACTORY_ZIP_PATH_OPTION = "artifactory-zip-path"; - - /** - * Option that specifies intermediate directories which should be appended. - */ - public static final String ARTIFACTORY_PATH_SUFFIX = "artifactory-path-suffix"; - - /** - * Specifies the location of the JAR file which includes the git.properties file. - */ - public static final String ARTIFACTORY_GIT_PROPERTIES_JAR_OPTION = "artifactory-git-properties-jar"; - - /** - * Specifies the date format in which the commit timestamp in the git.properties file is formatted. - */ - public static final String ARTIFACTORY_GIT_PROPERTIES_COMMIT_DATE_FORMAT_OPTION = "artifactory-git-properties-commit-date-format"; - - /** - * Specifies the partition for which the upload is. - */ - public static final String ARTIFACTORY_PARTITION = "artifactory-partition"; - - /** Related to {@link ArtifactoryConfig#ARTIFACTORY_USER_OPTION} */ - public HttpUrl url; - - /** Related to {@link ArtifactoryConfig#ARTIFACTORY_USER_OPTION} */ - public String user; - - /** Related to {@link ArtifactoryConfig#ARTIFACTORY_PASSWORD_OPTION} */ - public String password; - - /** Related to {@link ArtifactoryConfig#ARTIFACTORY_LEGACY_PATH_OPTION} */ - public boolean legacyPath = false; - - /** Related to {@link ArtifactoryConfig#ARTIFACTORY_ZIP_PATH_OPTION} */ - public String zipPath; - - /** Related to {@link ArtifactoryConfig#ARTIFACTORY_PATH_SUFFIX} */ - public String pathSuffix; - - /** The information regarding a commit. */ - public CommitInfo commitInfo; - - /** Related to {@link ArtifactoryConfig#ARTIFACTORY_API_KEY_OPTION} */ - public String apiKey; - - /** Related to {@link ArtifactoryConfig#ARTIFACTORY_PARTITION} */ - public String partition; - - /** - * Handles all command-line options prefixed with 'artifactory-' - * - * @return true if it has successfully processed the given option. - */ - public static boolean handleArtifactoryOptions(ArtifactoryConfig options, String key, String value) throws AgentOptionParseException { - switch (key) { - case ARTIFACTORY_URL_OPTION: - options.url = AgentOptionsParser.parseUrl(key, value); - return true; - case ARTIFACTORY_USER_OPTION: - options.user = value; - return true; - case ARTIFACTORY_PASSWORD_OPTION: - options.password = value; - return true; - case ARTIFACTORY_LEGACY_PATH_OPTION: - options.legacyPath = Boolean.parseBoolean(value); - return true; - case ARTIFACTORY_ZIP_PATH_OPTION: - options.zipPath = StringUtils.stripSuffix(value, "/"); - return true; - case ARTIFACTORY_PATH_SUFFIX: - options.pathSuffix = StringUtils.stripSuffix(value, "/"); - return true; - case ARTIFACTORY_API_KEY_OPTION: - options.apiKey = value; - return true; - case ARTIFACTORY_PARTITION: - options.partition = value; - return true; - default: - return false; - } - } - - /** Checks if all required options are set to upload to artifactory. */ - public boolean hasAllRequiredFieldsSet() { - boolean requiredAuthOptionsSet = (user != null && password != null) || apiKey != null; - boolean partitionSet = partition != null || legacyPath; - return url != null && partitionSet && requiredAuthOptionsSet; - } - - /** Checks if all required fields are null. */ - public boolean hasAllRequiredFieldsNull() { - return url == null && user == null && password == null && apiKey == null && partition == null; - } - - /** Checks whether commit and revision are set. */ - public boolean hasCommitInfo() { - return commitInfo != null; - } - - /** Parses the commit information form a git.properties file. */ - public static CommitInfo parseGitProperties( - File jarFile, boolean searchRecursively, @Nullable DateTimeFormatter gitPropertiesCommitTimeFormat) - throws UploaderException { - try { - List commitInfo = GitPropertiesLocatorUtils.getCommitInfoFromGitProperties(jarFile, true, searchRecursively, gitPropertiesCommitTimeFormat); - if (commitInfo.isEmpty()) { - throw new UploaderException("Found no git.properties files in " + jarFile); - } - if (commitInfo.size() > 1) { - throw new UploaderException("Found multiple git.properties files in " + jarFile - + ". Uploading to multiple projects is currently not possible with Artifactory. " - + "Please contact CQSE if you need this feature."); - } - return commitInfo.get(0); - } catch (IOException | InvalidGitPropertiesException e) { - throw new UploaderException("Could not locate a valid git.properties file in " + jarFile, e); - } - } -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/upload/artifactory/ArtifactoryUploader.java b/agent/src/main/java/com/teamscale/jacoco/agent/upload/artifactory/ArtifactoryUploader.java deleted file mode 100644 index 248fdc700..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/upload/artifactory/ArtifactoryUploader.java +++ /dev/null @@ -1,157 +0,0 @@ -package com.teamscale.jacoco.agent.upload.artifactory; - -import com.teamscale.client.CommitDescriptor; -import com.teamscale.client.EReportFormat; -import com.teamscale.client.FileSystemUtils; -import com.teamscale.client.HttpUtils; -import com.teamscale.client.StringUtils; -import com.teamscale.jacoco.agent.commit_resolution.git_properties.CommitInfo; -import com.teamscale.jacoco.agent.upload.HttpZipUploaderBase; -import com.teamscale.jacoco.agent.upload.IUploadRetry; -import com.teamscale.report.jacoco.CoverageFile; -import okhttp3.Interceptor; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.ResponseBody; -import retrofit2.Response; - -import java.io.File; -import java.io.FileWriter; -import java.io.IOException; -import java.nio.file.Path; -import java.util.List; -import java.util.Properties; - -import static com.teamscale.jacoco.agent.upload.teamscale.ETeamscaleServerProperties.COMMIT; -import static com.teamscale.jacoco.agent.upload.teamscale.ETeamscaleServerProperties.PARTITION; -import static com.teamscale.jacoco.agent.upload.teamscale.ETeamscaleServerProperties.REVISION; -import static com.teamscale.jacoco.agent.upload.teamscale.TeamscaleUploader.RETRY_UPLOAD_FILE_SUFFIX; - -/** - * Uploads XMLs to Artifactory. - */ -public class ArtifactoryUploader extends HttpZipUploaderBase implements IUploadRetry { - - /** - * Header that can be used as alternative to basic authentication to authenticate requests against artifactory. For - * details check https://www.jfrog.com/confluence/display/JFROG/Artifactory+REST+API - */ - public static final String ARTIFACTORY_API_HEADER = "X-JFrog-Art-Api"; - private final ArtifactoryConfig artifactoryConfig; - private final String coverageFormat; - private String uploadPath; - - /** Constructor. */ - public ArtifactoryUploader(ArtifactoryConfig config, List additionalMetaDataFiles, - EReportFormat reportFormat) { - super(config.url, additionalMetaDataFiles, IArtifactoryUploadApi.class); - this.artifactoryConfig = config; - this.coverageFormat = reportFormat.name().toLowerCase(); - } - - @Override - public void markFileForUploadRetry(CoverageFile coverageFile) { - File uploadMetadataFile = new File(FileSystemUtils.replaceFilePathFilenameWith( - FileSystemUtils.normalizeSeparators(coverageFile.toString()), - coverageFile.getName() + RETRY_UPLOAD_FILE_SUFFIX)); - Properties properties = createArtifactoryProperties(); - try (FileWriter writer = new FileWriter(uploadMetadataFile)) { - properties.store(writer, null); - } catch (IOException e) { - logger.warn( - "Failed to create metadata file for automatic upload retry of {}. Please manually retry the coverage upload to Azure.", - coverageFile); - uploadMetadataFile.delete(); - } - } - - @Override - public void reupload(CoverageFile coverageFile, Properties reuploadProperties) { - ArtifactoryConfig config = new ArtifactoryConfig(); - config.url = artifactoryConfig.url; - config.user = artifactoryConfig.user; - config.password = artifactoryConfig.password; - config.legacyPath = artifactoryConfig.legacyPath; - config.zipPath = artifactoryConfig.zipPath; - config.pathSuffix = artifactoryConfig.pathSuffix; - String revision = reuploadProperties.getProperty(REVISION.name()); - String commitString = reuploadProperties.getProperty(COMMIT.name()); - config.commitInfo = new CommitInfo(revision, CommitDescriptor.parse(commitString)); - config.apiKey = artifactoryConfig.apiKey; - config.partition = StringUtils.emptyToNull(reuploadProperties.getProperty(PARTITION.name())); - setUploadPath(coverageFile, config); - super.upload(coverageFile); - } - - /** Creates properties from the artifactory configs. */ - private Properties createArtifactoryProperties() { - Properties properties = new Properties(); - properties.setProperty(REVISION.name(), artifactoryConfig.commitInfo.revision); - properties.setProperty(COMMIT.name(), artifactoryConfig.commitInfo.commit.toString()); - properties.setProperty(PARTITION.name(), StringUtils.nullToEmpty(artifactoryConfig.partition)); - return properties; - } - - @Override - protected void configureOkHttp(OkHttpClient.Builder builder) { - super.configureOkHttp(builder); - if (artifactoryConfig.apiKey != null) { - builder.addInterceptor(getArtifactoryApiHeaderInterceptor()); - } else { - builder.addInterceptor( - HttpUtils.getBasicAuthInterceptor(artifactoryConfig.user, artifactoryConfig.password)); - } - } - - private void setUploadPath(CoverageFile coverageFile, ArtifactoryConfig artifactoryConfig) { - if (artifactoryConfig.legacyPath) { - this.uploadPath = String.join("/", artifactoryConfig.commitInfo.commit.branchName, - artifactoryConfig.commitInfo.commit.timestamp + "-" + artifactoryConfig.commitInfo.revision, - coverageFile.getNameWithoutExtension() + ".zip"); - } else if (artifactoryConfig.pathSuffix == null) { - this.uploadPath = String.join("/", "uploads", artifactoryConfig.commitInfo.commit.branchName, - artifactoryConfig.commitInfo.commit.timestamp + "-" + artifactoryConfig.commitInfo.revision, - artifactoryConfig.partition, coverageFormat, coverageFile.getNameWithoutExtension() + ".zip"); - } else { - this.uploadPath = String.join("/", "uploads", artifactoryConfig.commitInfo.commit.branchName, - artifactoryConfig.commitInfo.commit.timestamp + "-" + artifactoryConfig.commitInfo.revision, - artifactoryConfig.partition, coverageFormat, artifactoryConfig.pathSuffix, - coverageFile.getNameWithoutExtension() + ".zip"); - } - } - - @Override - public void upload(CoverageFile coverageFile) { - setUploadPath(coverageFile, this.artifactoryConfig); - super.upload(coverageFile); - } - - @Override - protected Response uploadCoverageZip(File zipFile) throws IOException { - return getApi().uploadCoverageZip(uploadPath, zipFile); - } - - @Override - protected String getZipEntryCoverageFileName(CoverageFile coverageFile) { - String path = coverageFile.getName(); - if (!StringUtils.isEmpty(artifactoryConfig.zipPath)) { - path = artifactoryConfig.zipPath + "/" + path; - } - - return path; - } - - /** {@inheritDoc} */ - @Override - public String describe() { - return "Uploading to " + uploadUrl; - } - - private Interceptor getArtifactoryApiHeaderInterceptor() { - return chain -> { - Request newRequest = chain.request().newBuilder().header(ARTIFACTORY_API_HEADER, artifactoryConfig.apiKey) - .build(); - return chain.proceed(newRequest); - }; - } -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/upload/artifactory/IArtifactoryUploadApi.java b/agent/src/main/java/com/teamscale/jacoco/agent/upload/artifactory/IArtifactoryUploadApi.java deleted file mode 100644 index 316e48a14..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/upload/artifactory/IArtifactoryUploadApi.java +++ /dev/null @@ -1,36 +0,0 @@ -/*-------------------------------------------------------------------------+ -| | -| Copyright (c) 2009-2018 CQSE GmbH | -| | -+-------------------------------------------------------------------------*/ -package com.teamscale.jacoco.agent.upload.artifactory; - -import okhttp3.MediaType; -import okhttp3.RequestBody; -import okhttp3.ResponseBody; -import retrofit2.Call; -import retrofit2.Response; -import retrofit2.Retrofit; -import retrofit2.http.Body; -import retrofit2.http.PUT; -import retrofit2.http.Path; - -import java.io.File; -import java.io.IOException; - -/** {@link Retrofit} API specification for the {@link ArtifactoryUploader}. */ -public interface IArtifactoryUploadApi { - - /** The upload API call. */ - @PUT("{path}") - Call upload(@Path("path") String path, @Body RequestBody uploadedFile); - - /** - * Convenience method to perform an upload for a coverage zip. - */ - default Response uploadCoverageZip(String path, File data) throws IOException { - RequestBody body = RequestBody.create(MediaType.parse("application/zip"), data); - return upload(path, body).execute(); - } - -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/upload/delay/DelayedUploader.java b/agent/src/main/java/com/teamscale/jacoco/agent/upload/delay/DelayedUploader.java deleted file mode 100644 index 178aee399..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/upload/delay/DelayedUploader.java +++ /dev/null @@ -1,116 +0,0 @@ -package com.teamscale.jacoco.agent.upload.delay; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.concurrent.Executor; -import java.util.concurrent.Executors; -import java.util.function.Function; -import java.util.stream.Stream; - -import org.slf4j.Logger; - -import com.teamscale.jacoco.agent.upload.IUploader; -import com.teamscale.jacoco.agent.util.DaemonThreadFactory; -import com.teamscale.jacoco.agent.logging.LoggingUtils; -import com.teamscale.report.jacoco.CoverageFile; - -/** - * Wraps an {@link IUploader} and in order to delay upload until a all - * information describing a commit is asynchronously made available. - */ -public class DelayedUploader implements IUploader { - - private final Executor executor; - private final Logger logger = LoggingUtils.getLogger(this); - private final Function wrappedUploaderFactory; - private IUploader wrappedUploader = null; - private final Path cacheDir; - - public DelayedUploader(Function wrappedUploaderFactory, Path cacheDir) { - this(wrappedUploaderFactory, cacheDir, Executors.newSingleThreadExecutor( - new DaemonThreadFactory(DelayedUploader.class, "Delayed cache upload thread"))); - } - - /** - * Visible for testing. Allows tests to control the {@link Executor} to test the - * asynchronous functionality of this class. - */ - /* package */ DelayedUploader(Function wrappedUploaderFactory, Path cacheDir, Executor executor) { - this.wrappedUploaderFactory = wrappedUploaderFactory; - this.cacheDir = cacheDir; - this.executor = executor; - - registerShutdownHook(); - } - - private void registerShutdownHook() { - Runtime.getRuntime().addShutdownHook(new Thread(() -> { - if (wrappedUploader == null) { - logger.error("The application was shut down before a commit could be found. The recorded coverage" - + " is still cached in {} but will not be automatically processed. You configured the" - + " agent to auto-detect the commit to which the recorded coverage should be uploaded to" - + " Teamscale. In order to fix this problem, you need to provide a git.properties file" - + " in all of the profiled Jar/War/Ear/... files. If you're using Gradle or" - + " Maven, you can use a plugin to create a proper git.properties file for you, see" - + " https://docs.spring.io/spring-boot/docs/current/reference/html/howto.html#howto-git-info" - + "\nTo debug problems with git.properties, please enable debug logging for the agent via" - + " the logging-config parameter.", cacheDir.toAbsolutePath()); - } - })); - } - - @Override - public synchronized void upload(CoverageFile file) { - if (wrappedUploader == null) { - logger.info("The commit to upload to has not yet been found. Caching coverage XML in {}", - cacheDir.toAbsolutePath()); - } else { - wrappedUploader.upload(file); - } - } - - @Override - public String describe() { - if (wrappedUploader != null) { - return wrappedUploader.describe(); - } - return "Temporary cache until commit is resolved: " + cacheDir.toAbsolutePath(); - } - - /** - * Sets the commit to upload the XMLs to and asynchronously triggers the upload - * of all cached XMLs. This method should only be called once. - */ - public synchronized void setCommitAndTriggerAsynchronousUpload(T information) { - if (wrappedUploader == null) { - wrappedUploader = wrappedUploaderFactory.apply(information); - logger.info("Commit to upload to has been found: {}. Uploading any cached XMLs now to {}", information, - wrappedUploader.describe()); - executor.execute(this::uploadCachedXmls); - } else { - logger.error( - "Tried to set upload commit multiple times (old uploader: {}, new commit: {})." - + " This is a programming error. Please report a bug.", - wrappedUploader.describe(), information); - } - } - - private void uploadCachedXmls() { - try { - if (!Files.isDirectory(cacheDir)) { - // Found data before XML was dumped - return; - } - Stream xmlFilesStream = Files.list(cacheDir).filter(path -> { - String fileName = path.getFileName().toString(); - return fileName.startsWith("jacoco-") && fileName.endsWith(".xml"); - }); - xmlFilesStream.forEach(path -> wrappedUploader.upload(new CoverageFile(path.toFile()))); - logger.debug("Finished upload of cached XMLs to {}", wrappedUploader.describe()); - } catch (IOException e) { - logger.error("Failed to list cached coverage XML files in {}", cacheDir.toAbsolutePath(), e); - } - - } -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/upload/teamscale/DelayedTeamscaleMultiProjectUploader.java b/agent/src/main/java/com/teamscale/jacoco/agent/upload/teamscale/DelayedTeamscaleMultiProjectUploader.java deleted file mode 100644 index 5c325925e..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/upload/teamscale/DelayedTeamscaleMultiProjectUploader.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.teamscale.jacoco.agent.upload.teamscale; - -import com.teamscale.client.TeamscaleServer; -import com.teamscale.jacoco.agent.commit_resolution.git_properties.CommitInfo; -import com.teamscale.jacoco.agent.options.ProjectAndCommit; -import com.teamscale.jacoco.agent.upload.DelayedMultiUploaderBase; -import com.teamscale.jacoco.agent.upload.IUploader; - -import java.io.File; -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; -import java.util.function.BiFunction; - -/** Wrapper for {@link TeamscaleUploader} that allows to upload the same coverage file to multiple Teamscale projects. */ -public class DelayedTeamscaleMultiProjectUploader extends DelayedMultiUploaderBase implements IUploader { - - private final BiFunction teamscaleServerFactory; - private final List teamscaleUploaders = new ArrayList<>(); - - public DelayedTeamscaleMultiProjectUploader( - BiFunction teamscaleServerFactory) { - this.teamscaleServerFactory = teamscaleServerFactory; - } - - public List getTeamscaleUploaders() { - return teamscaleUploaders; - } - - /** - * Adds a teamscale project and commit as a possible new target to upload coverage to. Checks if the project and - * commit are already registered as an upload target and will prevent duplicate uploads. - */ - public void addTeamscaleProjectAndCommit(File file, ProjectAndCommit projectAndCommit) { - - TeamscaleServer teamscaleServer = teamscaleServerFactory.apply(projectAndCommit.getProject(), - projectAndCommit.getCommitInfo()); - - if (this.teamscaleUploaders.stream().anyMatch(teamscaleUploader -> - teamscaleUploader.getTeamscaleServer().hasSameProjectAndCommit(teamscaleServer) - )) { - logger.debug( - "Project and commit in git.properties file {} are already registered as upload target. Coverage will not be uploaded multiple times to the same project {} and commit info {}.", - file, projectAndCommit.getProject(), projectAndCommit.getCommitInfo()); - return; - } - TeamscaleUploader uploader = new TeamscaleUploader(teamscaleServer); - teamscaleUploaders.add(uploader); - } - - @Override - protected Collection getWrappedUploaders() { - return new ArrayList<>(teamscaleUploaders); - } -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/upload/teamscale/ETeamscaleServerProperties.java b/agent/src/main/java/com/teamscale/jacoco/agent/upload/teamscale/ETeamscaleServerProperties.java deleted file mode 100644 index e2bfb413f..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/upload/teamscale/ETeamscaleServerProperties.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.teamscale.jacoco.agent.upload.teamscale; - -import com.teamscale.client.TeamscaleServer; - -/** Describes all the fields of the {@link TeamscaleServer}. */ -public enum ETeamscaleServerProperties { - - /** See {@link TeamscaleServer#url} */ - URL, - /** See {@link TeamscaleServer#project} */ - PROJECT, - /** See {@link TeamscaleServer#userName} */ - USER_NAME, - /** See {@link TeamscaleServer#userAccessToken} */ - USER_ACCESS_TOKEN, - /** See {@link TeamscaleServer#partition} */ - PARTITION, - /** See {@link TeamscaleServer#commit} */ - COMMIT, - /** See {@link TeamscaleServer#revision} */ - REVISION, - /** See {@link TeamscaleServer#repository} */ - REPOSITORY, - /** See {@link TeamscaleServer#getMessage()} */ - MESSAGE; -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/upload/teamscale/TeamscaleUploader.java b/agent/src/main/java/com/teamscale/jacoco/agent/upload/teamscale/TeamscaleUploader.java deleted file mode 100644 index 39517e38e..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/upload/teamscale/TeamscaleUploader.java +++ /dev/null @@ -1,156 +0,0 @@ -package com.teamscale.jacoco.agent.upload.teamscale; - -import com.teamscale.client.CommitDescriptor; -import com.teamscale.client.EReportFormat; -import com.teamscale.client.FileSystemUtils; -import com.teamscale.client.ITeamscaleService; -import com.teamscale.client.ITeamscaleServiceKt; -import com.teamscale.client.StringUtils; -import com.teamscale.client.TeamscaleServer; -import com.teamscale.client.TeamscaleServiceGenerator; -import com.teamscale.jacoco.agent.logging.LoggingUtils; -import com.teamscale.jacoco.agent.upload.IUploadRetry; -import com.teamscale.jacoco.agent.upload.IUploader; -import com.teamscale.jacoco.agent.util.AgentUtils; -import com.teamscale.jacoco.agent.util.Benchmark; -import com.teamscale.report.jacoco.CoverageFile; -import org.slf4j.Logger; - -import java.io.File; -import java.io.IOException; -import java.io.OutputStreamWriter; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.util.Properties; - -import static com.teamscale.jacoco.agent.upload.teamscale.ETeamscaleServerProperties.COMMIT; -import static com.teamscale.jacoco.agent.upload.teamscale.ETeamscaleServerProperties.MESSAGE; -import static com.teamscale.jacoco.agent.upload.teamscale.ETeamscaleServerProperties.PARTITION; -import static com.teamscale.jacoco.agent.upload.teamscale.ETeamscaleServerProperties.PROJECT; -import static com.teamscale.jacoco.agent.upload.teamscale.ETeamscaleServerProperties.REPOSITORY; -import static com.teamscale.jacoco.agent.upload.teamscale.ETeamscaleServerProperties.REVISION; - -/** Uploads XML Coverage to a Teamscale instance. */ -public class TeamscaleUploader implements IUploader, IUploadRetry { - - /** - * The properties file suffix for unsuccessful coverage uploads. - */ - public static final String RETRY_UPLOAD_FILE_SUFFIX = "_upload-retry.properties"; - - /** The logger. */ - private final Logger logger = LoggingUtils.getLogger(this); - - public TeamscaleServer getTeamscaleServer() { - return teamscaleServer; - } - - /** Teamscale server details. */ - private final TeamscaleServer teamscaleServer; - - /** Constructor. */ - public TeamscaleUploader(TeamscaleServer teamscaleServer) { - this.teamscaleServer = teamscaleServer; - } - - @Override - public void upload(CoverageFile coverageFile) { - doUpload(coverageFile, this.teamscaleServer); - } - - @Override - public void reupload(CoverageFile coverageFile, Properties reuploadProperties) { - TeamscaleServer server = new TeamscaleServer(); - server.project = reuploadProperties.getProperty(PROJECT.name()); - server.commit = CommitDescriptor.parse(reuploadProperties.getProperty(COMMIT.name())); - server.partition = reuploadProperties.getProperty(PARTITION.name()); - server.revision = StringUtils.emptyToNull(reuploadProperties.getProperty(REVISION.name())); - server.repository = StringUtils.emptyToNull(reuploadProperties.getProperty(REPOSITORY.name())); - server.userAccessToken = teamscaleServer.userAccessToken; - server.userName = teamscaleServer.userName; - server.url = teamscaleServer.url; - server.setMessage(reuploadProperties.getProperty(MESSAGE.name())); - doUpload(coverageFile, server); - } - - private void doUpload(CoverageFile coverageFile, TeamscaleServer teamscaleServer) { - try (Benchmark benchmark = new Benchmark("Uploading report to Teamscale")) { - if (tryUploading(coverageFile, teamscaleServer)) { - deleteCoverageFile(coverageFile); - } else { - logger.warn("Failed to upload coverage to Teamscale. " - + "Won't delete local file {} so that the upload can automatically be retried upon profiler restart. " - + "Upload can also be retried manually.", coverageFile); - markFileForUploadRetry(coverageFile); - } - } - } - - @Override - public void markFileForUploadRetry(CoverageFile coverageFile) { - File uploadMetadataFile = new File(FileSystemUtils.replaceFilePathFilenameWith( - FileSystemUtils.normalizeSeparators(coverageFile.toString()), - coverageFile.getName() + RETRY_UPLOAD_FILE_SUFFIX)); - Properties serverProperties = this.createServerProperties(); - try (OutputStreamWriter writer = new OutputStreamWriter(Files.newOutputStream(uploadMetadataFile.toPath()), - StandardCharsets.UTF_8)) { - serverProperties.store(writer, null); - } catch (IOException e) { - logger.warn( - "Failed to create metadata file for automatic upload retry of {}. Please manually retry the coverage upload to Teamscale.", - coverageFile); - uploadMetadataFile.delete(); - } - } - - /** - * Creates server properties to be written in a properties file. - */ - private Properties createServerProperties() { - Properties serverProperties = new Properties(); - serverProperties.setProperty(PROJECT.name(), teamscaleServer.project); - serverProperties.setProperty(PARTITION.name(), teamscaleServer.partition); - if (teamscaleServer.commit != null) { - serverProperties.setProperty(COMMIT.name(), teamscaleServer.commit.toString()); - } - serverProperties.setProperty(REVISION.name(), StringUtils.nullToEmpty(teamscaleServer.revision)); - serverProperties.setProperty(REPOSITORY.name(), StringUtils.nullToEmpty(teamscaleServer.repository)); - serverProperties.setProperty(MESSAGE.name(), teamscaleServer.getMessage()); - return serverProperties; - } - - private void deleteCoverageFile(CoverageFile coverageFile) { - try { - coverageFile.delete(); - } catch (IOException e) { - logger.warn("The upload to Teamscale was successful, but the deletion of the coverage file {} failed. " - + "You can delete it yourself anytime - it is no longer needed.", coverageFile, e); - } - } - - /** Performs the upload and returns true if successful. */ - private boolean tryUploading(CoverageFile coverageFile, TeamscaleServer teamscaleServer) { - logger.debug("Uploading JaCoCo artifact to {}", teamscaleServer); - - try { - // Cannot be executed in the constructor as this causes issues in WildFly server - // (See #100) - ITeamscaleService api = TeamscaleServiceGenerator.createService(ITeamscaleService.class, - teamscaleServer.url, teamscaleServer.userName, teamscaleServer.userAccessToken, - AgentUtils.USER_AGENT); - ITeamscaleServiceKt.uploadReport(api, teamscaleServer.project, teamscaleServer.commit, - teamscaleServer.revision, - teamscaleServer.repository, teamscaleServer.partition, EReportFormat.JACOCO, - teamscaleServer.getMessage(), coverageFile.createFormRequestBody()); - return true; - } catch (IOException e) { - logger.error("Failed to upload coverage to {}", teamscaleServer, e); - return false; - } - } - - @Override - public String describe() { - return "Uploading to " + teamscaleServer; - } -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/util/AgentUtils.java b/agent/src/main/java/com/teamscale/jacoco/agent/util/AgentUtils.java deleted file mode 100644 index e055ee8c1..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/util/AgentUtils.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.teamscale.jacoco.agent.util; - -import com.teamscale.client.FileSystemUtils; -import com.teamscale.client.TeamscaleServiceGenerator; -import com.teamscale.jacoco.agent.PreMain; -import com.teamscale.jacoco.agent.configuration.ProcessInformationRetriever; - -import java.io.IOException; -import java.net.URI; -import java.net.URISyntaxException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.ResourceBundle; - -/** General utilities for working with the agent. */ -public class AgentUtils { - - /** Version of this program. */ - public static final String VERSION; - - /** User-Agent header value for HTTP requests. */ - public static final String USER_AGENT; - - private static Path mainTempDirectory = null; - - static { - ResourceBundle bundle = ResourceBundle.getBundle("com.teamscale.jacoco.agent.app"); - VERSION = bundle.getString("version"); - USER_AGENT = TeamscaleServiceGenerator.buildUserAgent("Teamscale Java Profiler", VERSION); - } - - /** - * Returns the main temporary directory where all agent temp files should be placed. - */ - public static Path getMainTempDirectory() { - if (mainTempDirectory == null) { - try { - // We add a trailing hyphen here to visually separate the PID from the random number that Java appends - // to the name to make it unique - mainTempDirectory = Files.createTempDirectory("teamscale-java-profiler-" + - FileSystemUtils.toSafeFilename(ProcessInformationRetriever.getPID()) + "-"); - } catch (IOException e) { - throw new RuntimeException("Failed to create temporary directory for agent files", e); - } - } - return mainTempDirectory; - } - - /** Returns the directory that contains the agent installation. */ - public static Path getAgentDirectory() { - try { - URI jarFileUri = PreMain.class.getProtectionDomain().getCodeSource().getLocation().toURI(); - // we assume that the dist zip is extracted and the agent jar not moved - Path jarDirectory = Paths.get(jarFileUri).getParent(); - Path installDirectory = jarDirectory.getParent(); - if (installDirectory == null) { - // happens when the jar file is stored in the root directory - return jarDirectory; - } - return installDirectory; - } catch (URISyntaxException e) { - throw new RuntimeException("Failed to obtain agent directory. This is a bug, please report it.", e); - } - } - -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/util/Assertions.java b/agent/src/main/java/com/teamscale/jacoco/agent/util/Assertions.java deleted file mode 100644 index b789c5f6a..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/util/Assertions.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.teamscale.jacoco.agent.util; - -import org.jetbrains.annotations.Contract; - -/** - * Simple methods to implement assertions. - */ -public class Assertions { - - /** - * Checks if a condition is true. - * - * @param condition condition to check - * @param message exception message - * @throws AssertionError if the condition is false - */ - @Contract(value = "false, _ -> fail", pure = true) - public static void isTrue(boolean condition, String message) throws AssertionError { - throwAssertionErrorIfTestFails(condition, message); - } - - /** - * Checks if a condition is false. - * - * @param condition condition to check - * @param message exception message - * @throws AssertionError if the condition is true - */ - @Contract(value = "true, _ -> fail", pure = true) - public static void isFalse(boolean condition, String message) throws AssertionError { - throwAssertionErrorIfTestFails(!condition, message); - } - - /** - * Throws an {@link AssertionError} if the test fails. - * - * @param test test which should be true - * @param message exception message - * @throws AssertionError if the test fails - */ - private static void throwAssertionErrorIfTestFails(boolean test, String message) { - if (!test) { - throw new AssertionError(message); - } - } -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/util/DaemonThreadFactory.java b/agent/src/main/java/com/teamscale/jacoco/agent/util/DaemonThreadFactory.java deleted file mode 100644 index 1ec3461ec..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/util/DaemonThreadFactory.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.teamscale.jacoco.agent.util; - -import java.util.concurrent.ThreadFactory; - -/** - * {@link ThreadFactory} that only produces deamon threads (threads that don't prevent JVM shutdown) with a fixed name. - */ -public class DaemonThreadFactory implements ThreadFactory { - - private final String threadName; - - public DaemonThreadFactory(Class owningClass, String threadName) { - this.threadName = "Teamscale Java Profiler " + owningClass.getSimpleName() + " " + threadName; - } - - @Override - public Thread newThread(Runnable runnable) { - Thread thread = new Thread(runnable, threadName); - thread.setDaemon(true); - return thread; - } -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/util/NullOutputStream.java b/agent/src/main/java/com/teamscale/jacoco/agent/util/NullOutputStream.java deleted file mode 100644 index 856e316a1..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/util/NullOutputStream.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.teamscale.jacoco.agent.util; - -import org.jetbrains.annotations.NotNull; - -import java.io.IOException; -import java.io.OutputStream; - -/** NOP output stream implementation. */ -public class NullOutputStream extends OutputStream { - - public NullOutputStream() { - // do nothing - } - - @Override - public void write(final byte @NotNull [] b, final int off, final int len) { - // to /dev/null - } - - @Override - public void write(final int b) { - // to /dev/null - } - - @Override - public void write(final byte @NotNull [] b) throws IOException { - // to /dev/null - } -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/util/Timer.java b/agent/src/main/java/com/teamscale/jacoco/agent/util/Timer.java deleted file mode 100644 index a6787e085..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/util/Timer.java +++ /dev/null @@ -1,59 +0,0 @@ -/*-------------------------------------------------------------------------+ -| | -| Copyright (c) 2009-2018 CQSE GmbH | -| | -+-------------------------------------------------------------------------*/ -package com.teamscale.jacoco.agent.util; - -import java.time.Duration; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.ScheduledThreadPoolExecutor; -import java.util.concurrent.TimeUnit; - -/** - * Triggers a callback in a regular interval. Note that the spawned threads are - * Daemon threads, i.e. they will not prevent the JVM from shutting down. - *

- * The timer will abort if the given {@link #runnable} ever throws an exception. - */ -public class Timer { - - /** Runs the job on a background daemon thread. */ - private final ScheduledThreadPoolExecutor executor = new ScheduledThreadPoolExecutor(1, runnable -> { - Thread thread = Executors.defaultThreadFactory().newThread(runnable); - thread.setDaemon(true); - return thread; - }); - - /** The currently running job or null. */ - private ScheduledFuture job = null; - - /** The job to execute periodically. */ - private final Runnable runnable; - - /** Duration between two job executions. */ - private final Duration duration; - - /** Constructor. */ - public Timer(Runnable runnable, Duration duration) { - this.runnable = runnable; - this.duration = duration; - } - - /** Starts the regular job. */ - public synchronized void start() { - if (job != null) { - return; - } - - job = executor.scheduleAtFixedRate(runnable, duration.toMinutes(), duration.toMinutes(), TimeUnit.MINUTES); - } - - /** Stops the regular job, possibly aborting it. */ - public synchronized void stop() { - job.cancel(false); - job = null; - } - -} diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/Agent.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/Agent.kt new file mode 100644 index 000000000..010e88409 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/Agent.kt @@ -0,0 +1,170 @@ +package com.teamscale.jacoco.agent + +import com.teamscale.client.FileSystemUtils +import com.teamscale.client.StringUtils +import com.teamscale.jacoco.agent.logging.LoggingUtils +import com.teamscale.jacoco.agent.options.AgentOptions +import com.teamscale.jacoco.agent.upload.IUploadRetry +import com.teamscale.jacoco.agent.upload.IUploader +import com.teamscale.jacoco.agent.upload.teamscale.TeamscaleUploader +import com.teamscale.jacoco.agent.util.AgentUtils +import com.teamscale.report.jacoco.CoverageFile +import com.teamscale.report.jacoco.EmptyReportException +import com.teamscale.report.jacoco.JaCoCoXmlReportGenerator +import com.teamscale.report.jacoco.dump.Dump +import org.glassfish.jersey.server.ResourceConfig +import org.glassfish.jersey.server.ServerProperties +import java.io.File +import java.io.IOException +import java.lang.instrument.Instrumentation +import java.nio.file.Files +import java.util.Timer +import kotlin.concurrent.fixedRateTimer +import kotlin.io.path.deleteIfExists +import kotlin.io.path.listDirectoryEntries +import kotlin.time.DurationUnit +import kotlin.time.toDuration + +/** + * A wrapper around the JaCoCo Java agent that automatically triggers a dump and XML conversion based on a time + * interval. + */ +class Agent(options: AgentOptions, instrumentation: Instrumentation?) : AgentBase(options) { + /** Converts binary data to XML. */ + private val generator: JaCoCoXmlReportGenerator + + /** Regular dump task. */ + private var timer: Timer? = null + + /** Stores the XML files. */ + private val uploader = options.createUploader(instrumentation) + + /** Constructor. */ + init { + logger.info("Upload method: {}", uploader.describe()) + retryUnsuccessfulUploads(options, uploader) + generator = JaCoCoXmlReportGenerator( + options.getClassDirectoriesOrZips(), + options.locationIncludeFilter, + options.getDuplicateClassFileBehavior(), + options.shouldIgnoreUncoveredClasses(), + LoggingUtils.wrap(logger) + ) + + if (options.shouldDumpInIntervals()) { + val period = options.dumpIntervalInMinutes.toDuration(DurationUnit.MINUTES).inWholeMilliseconds + timer = fixedRateTimer("Teamscale-Java-Profiler", true, period, period) { + dumpReport() + } + logger.info("Dumping every ${options.dumpIntervalInMinutes} minutes.") + } + options.teamscaleServerOptions.partition?.let { partition -> + controller.sessionId = partition + } + } + + /** + * If we have coverage that was leftover because of previously unsuccessful coverage uploads, we retry to upload + * them again with the same configuration as in the previous try. + */ + private fun retryUnsuccessfulUploads(options: AgentOptions, uploader: IUploader) { + var outputPath = options.outputDirectory + if (outputPath == null) { + // Default fallback + outputPath = AgentUtils.agentDirectory.resolve("coverage") + } + + val parentPath = outputPath.parent + if (parentPath == null) { + logger.error("The output path '{}' does not have a parent path. Canceling upload retry.", outputPath.toAbsolutePath()) + return + } + + parentPath.toFile().walk() + .filter { it.name.endsWith(TeamscaleUploader.RETRY_UPLOAD_FILE_SUFFIX) } + .forEach { file -> + reuploadCoverageFromPropertiesFile(file, uploader) + } + } + + private fun reuploadCoverageFromPropertiesFile(file: File, uploader: IUploader) { + logger.info("Retrying previously unsuccessful coverage upload for file {}.", file) + try { + val properties = FileSystemUtils.readProperties(file) + val coverageFile = CoverageFile( + File(StringUtils.stripSuffix(file.absolutePath, TeamscaleUploader.RETRY_UPLOAD_FILE_SUFFIX)) + ) + + if (uploader is IUploadRetry) { + uploader.reupload(coverageFile, properties) + } else { + logger.info("Reupload not implemented for uploader {}", uploader.describe()) + } + Files.deleteIfExists(file.toPath()) + } catch (e: IOException) { + logger.error("Reuploading coverage failed. $e") + } + } + + override fun initResourceConfig(): ResourceConfig? { + val resourceConfig = ResourceConfig() + resourceConfig.property(ServerProperties.WADL_FEATURE_DISABLE, true.toString()) + AgentResource.setAgent(this) + return resourceConfig.register(AgentResource::class.java).register(GenericExceptionMapper::class.java) + } + + override fun prepareShutdown() { + timer?.cancel() + if (options.shouldDumpOnExit()) dumpReport() + + val dir = options.outputDirectory + try { + if (dir.listDirectoryEntries().isEmpty()) dir.deleteIfExists() + } catch (e: IOException) { + logger.info( + ("Could not delete empty output directory {}. " + + "This directory was created inside the configured output directory to be able to " + + "distinguish between different runs of the profiled JVM. You may delete it manually."), + dir, e + ) + } + } + + /** + * Dumps the current execution data, converts it, writes it to the output directory defined in [.options] and + * uploads it if an uploader is configured. Logs any errors, never throws an exception. + */ + override fun dumpReport() { + logger.debug("Starting dump") + + try { + dumpReportUnsafe() + } catch (t: Throwable) { + // we want to catch anything in order to avoid crashing the whole system under + // test + logger.error("Dump job failed with an exception", t) + } + } + + private fun dumpReportUnsafe() { + val dump: Dump + try { + dump = controller.dumpAndReset() + } catch (e: JacocoRuntimeController.DumpException) { + logger.error("Dumping failed, retrying later", e) + return + } + + try { + benchmark("Generating the XML report") { + val outputFile = options.createNewFileInOutputDirectory("jacoco", "xml") + val coverageFile = generator.convertSingleDumpToReport(dump, outputFile) + uploader.upload(coverageFile) + } + } catch (e: IOException) { + logger.error("Converting binary dump to XML failed", e) + } catch (e: EmptyReportException) { + logger.error("No coverage was collected. ${e.message}", e) + } + } +} \ No newline at end of file diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/AgentBase.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/AgentBase.kt new file mode 100644 index 000000000..bfed53514 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/AgentBase.kt @@ -0,0 +1,150 @@ +package com.teamscale.jacoco.agent + +import com.teamscale.jacoco.agent.logging.LoggingUtils +import com.teamscale.jacoco.agent.options.AgentOptions +import org.eclipse.jetty.server.Server +import org.eclipse.jetty.server.ServerConnector +import org.eclipse.jetty.servlet.ServletContextHandler +import org.eclipse.jetty.servlet.ServletHolder +import org.eclipse.jetty.util.thread.QueuedThreadPool +import org.glassfish.jersey.server.ResourceConfig +import org.glassfish.jersey.servlet.ServletContainer +import org.jacoco.agent.rt.RT +import org.slf4j.Logger +import java.lang.management.ManagementFactory + +/** + * Base class for agent implementations. Handles logger shutdown, store creation and instantiation of the + * [JacocoRuntimeController]. + * + * + * Subclasses must handle dumping onto disk and uploading via the configured uploader. + */ +abstract class AgentBase( + /** The agent options. */ + @JvmField var options: AgentOptions +) { + /** The logger. */ + val logger: Logger = LoggingUtils.getLogger(this) + + /** Controls the JaCoCo runtime. */ + @JvmField + val controller: JacocoRuntimeController + + private lateinit var server: Server + + /** + * Lazily generated string representation of the command line arguments to print to the log. + */ + private val optionsObjectToLog by lazy { + object { + override fun toString() = + if (options.shouldObfuscateSecurityRelatedOutputs()) { + options.getObfuscatedOptionsString() + } else { + options.getOriginalOptionsString() + } + } + } + + init { + try { + controller = JacocoRuntimeController(RT.getAgent()) + } catch (e: IllegalStateException) { + throw IllegalStateException("Teamscale Java Profiler not started or there is a conflict with another agent on the classpath.", e) + } + logger.info( + "Starting Teamscale Java Profiler for process {} with options: {}", + ManagementFactory.getRuntimeMXBean().name, optionsObjectToLog + ) + options.getHttpServerPort()?.let { port -> + try { + initServer() + } catch (e: Exception) { + logger.error("Could not start http server on port $port. Please check if the port is blocked.") + throw IllegalStateException("Control server not started.", e) + } + } + } + + /** + * Starts the http server, which waits for information about started and finished tests. + */ + @Throws(Exception::class) + private fun initServer() { + logger.info("Listening for test events on port {}.", options.getHttpServerPort()) + + // Jersey Implementation + val handler = buildUsingResourceConfig() + val threadPool = QueuedThreadPool() + threadPool.maxThreads = 10 + threadPool.isDaemon = true + + // Create a server instance and set the thread pool + server = Server(threadPool) + // Create a server connector, set the port and add it to the server + val connector = ServerConnector(server) + connector.port = options.getHttpServerPort() + server.addConnector(connector) + server.handler = handler + server.start() + } + + private fun buildUsingResourceConfig(): ServletContextHandler { + val handler = ServletContextHandler(ServletContextHandler.NO_SESSIONS) + handler.contextPath = "/" + + val resourceConfig = initResourceConfig() + handler.addServlet(ServletHolder(ServletContainer(resourceConfig)), "/*") + return handler + } + + /** + * Initializes the [ResourceConfig] needed for the Jetty + Jersey Server + */ + protected abstract fun initResourceConfig(): ResourceConfig? + + /** + * Registers a shutdown hook that stops the timer and dumps coverage a final time. + */ + fun registerShutdownHook() { + Runtime.getRuntime().addShutdownHook(Thread { + try { + logger.info("Teamscale Java Profiler is shutting down...") + stopServer() + prepareShutdown() + logger.info("Teamscale Java Profiler successfully shut down.") + } catch (e: Exception) { + logger.error("Exception during profiler shutdown.", e) + } finally { + // Try to flush logging resources also in case of an exception during shutdown + PreMain.closeLoggingResources() + } + }) + } + + /** Stop the http server if it's running */ + fun stopServer() { + options.getHttpServerPort()?.let { + try { + server.stop() + } catch (e: Exception) { + logger.error("Could not stop server so it is killed now.", e) + } finally { + server.destroy() + } + } + } + + /** Called when the shutdown hook is triggered. */ + protected open fun prepareShutdown() { + // Template method to be overridden by subclasses. + } + + /** + * Dumps the current execution data, converts it, writes it to the output + * directory defined in [.options] and uploads it if an uploader is + * configured. Logs any errors, never throws an exception. + */ + abstract fun dumpReport() +} \ No newline at end of file diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/AgentResource.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/AgentResource.kt new file mode 100644 index 000000000..94447f400 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/AgentResource.kt @@ -0,0 +1,41 @@ +package com.teamscale.jacoco.agent + +import javax.ws.rs.POST +import javax.ws.rs.Path +import javax.ws.rs.core.Response + +/** + * The resource of the Jersey + Jetty http server holding all the endpoints specific for the [Agent]. + */ +@Path("/") +class AgentResource : ResourceBase() { + /** Handles dumping a XML coverage report for coverage collected until now. */ + @POST + @Path("/dump") + fun handleDump(): Response? { + logger.debug("Dumping report triggered via HTTP request") + agent.dumpReport() + return Response.noContent().build() + } + + /** Handles resetting of coverage. */ + @POST + @Path("/reset") + fun handleReset(): Response? { + logger.debug("Resetting coverage triggered via HTTP request") + agent.controller.reset() + return Response.noContent().build() + } + + companion object { + private lateinit var agent: Agent + + /** + * Static setter to inject the [Agent] to the resource. + */ + fun setAgent(agent: Agent) { + Companion.agent = agent + agentBase = agent + } + } +} \ No newline at end of file diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/DelayedLogger.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/DelayedLogger.kt new file mode 100644 index 000000000..d242ff79d --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/DelayedLogger.kt @@ -0,0 +1,52 @@ +package com.teamscale.jacoco.agent + +import com.teamscale.report.util.ILogger +import org.slf4j.Logger +import java.util.function.Consumer + +/** + * A logger that buffers logs in memory and writes them to the actual logger at a later point. This is needed when stuff + * needs to be logged before the actual logging framework is initialized. + */ +class DelayedLogger : ILogger { + /** List of log actions that will be executed once the logger is initialized. */ + private val logActions = mutableListOf Unit>() + + override fun debug(message: String) { + logActions.add { debug(message) } + } + + override fun info(message: String) { + logActions.add { info(message) } + } + + override fun warn(message: String) { + logActions.add { warn(message) } + } + + override fun warn(message: String, throwable: Throwable?) { + logActions.add { warn(message, throwable) } + } + + override fun error(throwable: Throwable) { + logActions.add { error(throwable.message, throwable) } + } + + override fun error(message: String, throwable: Throwable?) { + logActions.add { error(message, throwable) } + } + + /** + * Logs an error and also writes the message to [System.err] to ensure the message is even logged in case + * setting up the logger itself fails for some reason (see TS-23151). + */ + fun errorAndStdErr(message: String?, throwable: Throwable?) { + System.err.println(message) + logActions.add { error(message, throwable) } + } + + /** Writes the logs to the given slf4j logger. */ + fun logTo(logger: Logger) { + logActions.forEach { action -> action(logger) } + } +} \ No newline at end of file diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/GenericExceptionMapper.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/GenericExceptionMapper.kt new file mode 100644 index 000000000..c7f0f9909 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/GenericExceptionMapper.kt @@ -0,0 +1,18 @@ +package com.teamscale.jacoco.agent + +import javax.ws.rs.core.MediaType +import javax.ws.rs.core.Response +import javax.ws.rs.ext.ExceptionMapper +import javax.ws.rs.ext.Provider + +/** + * Generates a [javax.ws.rs.core.Response] for an exception. + */ +@Provider +class GenericExceptionMapper : ExceptionMapper { + override fun toResponse(e: Throwable?): Response = + Response.status(Response.Status.INTERNAL_SERVER_ERROR).apply { + type(MediaType.TEXT_PLAIN_TYPE) + entity("Message: ${e?.message}") + }.build() +} \ No newline at end of file diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/Helpers.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/Helpers.kt new file mode 100644 index 000000000..d3dc43a97 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/Helpers.kt @@ -0,0 +1,6 @@ +package com.teamscale.jacoco.agent + +import kotlin.time.measureTime + +fun benchmark(name: String, action: () -> Unit) = + measureTime { action() }.also { duration -> Main.logger.debug("$name took $duration") } \ No newline at end of file diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/JacocoRuntimeController.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/JacocoRuntimeController.kt new file mode 100644 index 000000000..0a76c0d3e --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/JacocoRuntimeController.kt @@ -0,0 +1,118 @@ +package com.teamscale.jacoco.agent + +import com.teamscale.report.jacoco.dump.Dump +import org.jacoco.agent.rt.IAgent +import org.jacoco.core.data.ExecutionData +import org.jacoco.core.data.ExecutionDataReader +import org.jacoco.core.data.ExecutionDataStore +import org.jacoco.core.data.IExecutionDataVisitor +import org.jacoco.core.data.ISessionInfoVisitor +import org.jacoco.core.data.SessionInfo +import java.io.ByteArrayInputStream +import java.io.File +import java.io.FileOutputStream +import java.io.IOException + +/** + * Wrapper around JaCoCo's [RT] runtime interface. + * + * + * Can be used if the calling code is run in the same JVM as the agent is attached to. + */ +class JacocoRuntimeController +/** Constructor. */( + /** JaCoCo's [RT] agent instance */ + private val agent: IAgent +) { + /** Indicates a failed dump. */ + class DumpException(message: String?, cause: Throwable?) : Exception(message, cause) + + /** + * Dumps execution data and resets it. + * + * @throws DumpException if dumping fails. This should never happen in real life. Dumping should simply be retried + * later if this ever happens. + */ + @Throws(DumpException::class) + fun dumpAndReset(): Dump { + val binaryData = agent.getExecutionData(true) + + try { + ByteArrayInputStream(binaryData).use { inputStream -> + ExecutionDataReader(inputStream).apply { + val store = ExecutionDataStore() + setExecutionDataVisitor { store.put(it) } + val sessionInfoVisitor = SessionInfoVisitor() + setSessionInfoVisitor(sessionInfoVisitor) + read() + return Dump(sessionInfoVisitor.sessionInfo, store) + } + } + } catch (e: IOException) { + throw DumpException("should never happen for the ByteArrayInputStream", e) + } + } + + /** + * Dumps execution data to the given file and resets it afterwards. + */ + @Throws(IOException::class) + fun dumpToFileAndReset(file: File) { + val binaryData = agent.getExecutionData(true) + + FileOutputStream(file, true).use { outputStream -> + outputStream.write(binaryData) + } + } + + + /** + * Dumps execution data to a file and resets it. + * + * @throws DumpException if dumping fails. This should never happen in real life. Dumping should simply be retried + * later if this ever happens. + */ + @Throws(DumpException::class) + fun dump() { + try { + agent.dump(true) + } catch (e: IOException) { + throw DumpException(e.message, e) + } + } + + /** Resets already collected coverage. */ + fun reset() { + agent.reset() + } + + var sessionId: String? + /** Returns the current sessionId. */ + get() = agent.sessionId + /** + * Sets the current sessionId of the agent that can be used to identify which coverage is recorded from now on. + */ + set(sessionId) { + agent.setSessionId(sessionId) + } + + /** Unsets the session ID so that coverage collected from now on is not attributed to the previous test. */ + fun resetSessionId() { + agent.sessionId = "" + } + + /** + * Receives and stores a [org.jacoco.core.data.SessionInfo]. Has a fallback dummy session in case nothing is received. + */ + private class SessionInfoVisitor : ISessionInfoVisitor { + /** The received session info or a dummy. */ + var sessionInfo: SessionInfo = SessionInfo( + "dummysession", System.currentTimeMillis(), System.currentTimeMillis() + ) + + /** {@inheritDoc} */ + override fun visitSessionInfo(info: SessionInfo) { + this.sessionInfo = info + } + } +} \ No newline at end of file diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/LenientCoverageTransformer.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/LenientCoverageTransformer.kt new file mode 100644 index 000000000..dd9094e71 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/LenientCoverageTransformer.kt @@ -0,0 +1,51 @@ +package com.teamscale.jacoco.agent + +import org.jacoco.agent.rt.internal_29a6edd.CoverageTransformer +import org.jacoco.agent.rt.internal_29a6edd.IExceptionLogger +import org.jacoco.agent.rt.internal_29a6edd.core.runtime.AgentOptions +import org.jacoco.agent.rt.internal_29a6edd.core.runtime.IRuntime +import org.slf4j.Logger +import java.lang.instrument.IllegalClassFormatException +import java.security.ProtectionDomain + +/** + * A class file transformer which delegates to the JaCoCo [org.jacoco.agent.rt.internal_29a6edd.CoverageTransformer] to do the actual instrumentation, + * but treats instrumentation errors e.g. due to unsupported class file versions more lenient by only logging them, but + * not bailing out completely. Those unsupported classes will not be instrumented and will therefore not be contained in + * the collected coverage report. + */ +class LenientCoverageTransformer( + runtime: IRuntime?, + options: AgentOptions, + private val logger: Logger +) : CoverageTransformer( + runtime, + options, + // The coverage transformer only uses the logger to print an error when the instrumentation fails. + // We want to show our more specific error message instead, so we only log this for debugging at trace. + IExceptionLogger { logger.trace(it.message, it) } +) { + override fun transform( + loader: ClassLoader?, + classname: String, + classBeingRedefined: Class<*>?, + protectionDomain: ProtectionDomain?, + classfileBuffer: ByteArray + ): ByteArray? { + try { + return super.transform(loader, classname, classBeingRedefined, protectionDomain, classfileBuffer) + } catch (e: IllegalClassFormatException) { + logger.error( + "Failed to instrument $classname. File will be skipped from instrumentation. " + + "No coverage will be collected for it. Exclude the file from the instrumentation or try " + + "updating the Teamscale Java Profiler if the file should actually be instrumented. (Cause: ${getRootCauseMessage(e)})" + ) + return null + } + } + + companion object { + private fun getRootCauseMessage(e: Throwable): String? = + e.cause?.let { getRootCauseMessage(it) } ?: e.message + } +} \ No newline at end of file diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/Main.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/Main.kt new file mode 100644 index 000000000..e1efc8a12 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/Main.kt @@ -0,0 +1,81 @@ +package com.teamscale.jacoco.agent + +import com.beust.jcommander.JCommander +import com.beust.jcommander.Parameter +import com.beust.jcommander.ParameterException +import com.teamscale.client.StringUtils +import com.teamscale.jacoco.agent.convert.ConvertCommand +import com.teamscale.jacoco.agent.logging.LoggingUtils +import com.teamscale.jacoco.agent.util.AgentUtils +import org.jacoco.core.JaCoCo +import org.slf4j.Logger +import kotlin.system.exitProcess + +/** Provides a command line interface for interacting with JaCoCo. */ +object Main { + /** The logger. */ + val logger: Logger = LoggingUtils.getLogger(this) + + /** The default arguments that will always be parsed. */ + private val defaultArguments = DefaultArguments() + + /** The arguments for the one-time conversion process. */ + private val command = ConvertCommand() + + /** Entry point. */ + @Throws(Exception::class) + @JvmStatic + fun main(args: Array) { + parseCommandLineAndRun(args) + } + + /** + * Parses the given command line arguments. Exits the program or throws an exception if the arguments are not valid. + * Then runs the specified command. + */ + @Throws(Exception::class) + private fun parseCommandLineAndRun(args: Array) { + val builder = createJCommanderBuilder() + val jCommander = builder.build() + + try { + jCommander.parse(*args) + } catch (e: ParameterException) { + handleInvalidCommandLine(jCommander, e.message) + } + + if (defaultArguments.help) { + println("Teamscale Java Profiler ${AgentUtils.VERSION} compiled against JaCoCo ${JaCoCo.VERSION}") + jCommander.usage() + return + } + + val validator = command.validate() + if (!validator.isValid) { + handleInvalidCommandLine(jCommander, StringUtils.LINE_FEED + validator.errorMessage) + } + + logger.info("Starting Teamscale Java Profiler ${AgentUtils.VERSION} compiled against JaCoCo ${JaCoCo.VERSION}") + command.run() + } + + /** Shows an informative error and help message. Then exits the program. */ + private fun handleInvalidCommandLine(jCommander: JCommander, message: String?) { + System.err.println("Invalid command line: $message${StringUtils.LINE_FEED}") + jCommander.usage() + exitProcess(1) + } + + /** Creates a builder for a [com.beust.jcommander.JCommander] object. */ + private fun createJCommanderBuilder() = + JCommander.newBuilder().programName(Main::class.java.getName()) + .addObject(defaultArguments) + .addObject(command) + + /** Default arguments that may always be provided. */ + private class DefaultArguments { + /** Shows the help message. */ + @Parameter(names = ["--help"], help = true, description = "Shows all available command line arguments.") + val help = false + } +} \ No newline at end of file diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/PreMain.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/PreMain.kt new file mode 100644 index 000000000..37f157af1 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/PreMain.kt @@ -0,0 +1,306 @@ +package com.teamscale.jacoco.agent + +import com.teamscale.client.FileSystemUtils +import com.teamscale.client.HttpUtils +import com.teamscale.client.StringUtils +import com.teamscale.jacoco.agent.configuration.AgentOptionReceiveException +import com.teamscale.jacoco.agent.logging.DebugLogDirectoryPropertyDefiner +import com.teamscale.jacoco.agent.logging.LogDirectoryPropertyDefiner +import com.teamscale.jacoco.agent.logging.LogToTeamscaleAppender +import com.teamscale.jacoco.agent.logging.LoggingUtils +import com.teamscale.jacoco.agent.options.AgentOptionParseException +import com.teamscale.jacoco.agent.options.AgentOptions +import com.teamscale.jacoco.agent.options.AgentOptionsParser +import com.teamscale.jacoco.agent.options.FilePatternResolver +import com.teamscale.jacoco.agent.options.JacocoAgentOptionsBuilder +import com.teamscale.jacoco.agent.options.TeamscalePropertiesUtils +import com.teamscale.jacoco.agent.testimpact.TestwiseCoverageAgent +import com.teamscale.jacoco.agent.upload.UploaderException +import com.teamscale.jacoco.agent.util.AgentUtils +import com.teamscale.report.util.ILogger +import java.io.IOException +import java.lang.instrument.Instrumentation +import java.lang.management.ManagementFactory +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import kotlin.use + +/** Container class for the premain entry point for the agent. */ +object PreMain { + private lateinit var loggingResources: LoggingUtils.LoggingResources + + /** + * System property that we use to prevent this agent from being attached to the same VM twice. This can happen if + * the agent is registered via multiple JVM environment variables and/or the command line at the same time. + */ + private const val LOCKING_SYSTEM_PROPERTY = "TEAMSCALE_JAVA_PROFILER_ATTACHED" + + /** + * Environment variable from which to read the config ID to use. This is an ID for a profiler configuration that is + * stored in Teamscale. + */ + private const val CONFIG_ID_ENVIRONMENT_VARIABLE = "TEAMSCALE_JAVA_PROFILER_CONFIG_ID" + + /** Environment variable from which to read the config file to use. */ + private const val CONFIG_FILE_ENVIRONMENT_VARIABLE = "TEAMSCALE_JAVA_PROFILER_CONFIG_FILE" + + /** Environment variable from which to read the Teamscale access token. */ + private const val ACCESS_TOKEN_ENVIRONMENT_VARIABLE = "TEAMSCALE_ACCESS_TOKEN" + + /** + * Entry point for the agent, called by the JVM. + */ + @JvmStatic + @Throws(Exception::class) + fun premain(options: String?, instrumentation: Instrumentation?) { + if (System.getProperty(LOCKING_SYSTEM_PROPERTY) != null) return + System.setProperty(LOCKING_SYSTEM_PROPERTY, "true") + + val environmentConfigId = System.getenv(CONFIG_ID_ENVIRONMENT_VARIABLE) + val environmentConfigFile = System.getenv(CONFIG_FILE_ENVIRONMENT_VARIABLE) + if (StringUtils.isEmpty(options) && environmentConfigId == null && environmentConfigFile == null) { + // profiler was registered globally, and no config was set explicitly by the user, thus ignore this process + // and don't profile anything + return + } + + var agentOptions: AgentOptions? = null + try { + val parseResult = getAndApplyAgentOptions( + options, environmentConfigId, environmentConfigFile + ) + agentOptions = parseResult.first + + // After parsing everything and configuring logging, we now + // can throw the caught exceptions. + parseResult.second?.forEach { exception -> + throw exception + } + } catch (e: AgentOptionParseException) { + LoggingUtils.loggerContext.getLogger(PreMain::class.java).error(e.message, e) + + // Flush logs to Teamscale, if configured. + closeLoggingResources() + + // Unregister the profiler from Teamscale. + agentOptions?.configurationViaTeamscale?.unregisterProfiler() + + throw e + } catch (_: AgentOptionReceiveException) { + // When Teamscale is not available, we don't want to fail hard to still allow for testing even if no + // coverage is collected (see TS-33237) + return + } + + val logger = LoggingUtils.getLogger(Agent::class.java) + + logger.info("Teamscale Java profiler version ${AgentUtils.VERSION}") + logger.info("Starting JaCoCo's agent") + val agentBuilder = JacocoAgentOptionsBuilder(agentOptions) + JaCoCoPreMain.premain(agentBuilder.createJacocoAgentOptions(), instrumentation, logger) + + agentOptions.configurationViaTeamscale?.startHeartbeatThreadAndRegisterShutdownHook() + createAgent(agentOptions, instrumentation).registerShutdownHook() + } + + @Throws(AgentOptionParseException::class, IOException::class, AgentOptionReceiveException::class) + private fun getAndApplyAgentOptions( + options: String?, + environmentConfigId: String?, + environmentConfigFile: String? + ): Pair?> { + val delayedLogger = DelayedLogger() + val javaAgents = ManagementFactory.getRuntimeMXBean().inputArguments + .filter { it.contains("-javaagent") } + // We allow multiple instances of the teamscale-jacoco-agent as we ensure with the #LOCKING_SYSTEM_PROPERTY to only use it once + val differentAgents = javaAgents.filter { !it.contains("teamscale-jacoco-agent.jar") } + + if (!differentAgents.isEmpty()) { + delayedLogger.warn( + "Using multiple java agents could interfere with coverage recording: ${ + differentAgents.joinToString() + }" + ) + } + if (!javaAgents.first().contains("teamscale-jacoco-agent.jar")) { + delayedLogger.warn("For best results consider registering the Teamscale Java Profiler first.") + } + + val credentials = TeamscalePropertiesUtils.parseCredentials() + if (credentials == null) { + // As many users still don't use the installer based setup, this log message will be shown in almost every log. + // We use a debug log, as this message can be confusing for customers that think a teamscale.properties file is synonymous with a config file. + delayedLogger.debug( + "No explicit teamscale.properties file given. Looking for Teamscale credentials in a config file or via a command line argument. This is expected unless the installer based setup was used." + ) + } + + val environmentAccessToken = System.getenv(ACCESS_TOKEN_ENVIRONMENT_VARIABLE) + + val parseResult: Pair> + val agentOptions: AgentOptions + try { + parseResult = AgentOptionsParser.parse( + options, environmentConfigId, environmentConfigFile, credentials, environmentAccessToken, delayedLogger + ) + agentOptions = parseResult.first + } catch (e: AgentOptionParseException) { + initializeFallbackLogging(options, delayedLogger).use { _ -> + delayedLogger.errorAndStdErr("Failed to parse agent options: ${e.message}", e) + attemptLogAndThrow(delayedLogger) + throw e + } + } catch (e: AgentOptionReceiveException) { + initializeFallbackLogging(options, delayedLogger).use { _ -> + delayedLogger.errorAndStdErr("${e.message} The application should start up normally, but NO coverage will be collected! Check the log file for details.", e) + attemptLogAndThrow(delayedLogger) + throw e + } + } + + initializeLogging(agentOptions, delayedLogger) + val logger = LoggingUtils.getLogger(Agent::class.java) + delayedLogger.logTo(logger) + HttpUtils.setShouldValidateSsl(agentOptions.shouldValidateSsl()) + + return parseResult + } + + private fun attemptLogAndThrow(delayedLogger: DelayedLogger) { + // We perform actual logging output after writing to console to + // ensure the console is reached even in case of logging issues + // (see TS-23151). We use the Agent class here (same as below) + val logger = LoggingUtils.getLogger(Agent::class.java) + delayedLogger.logTo(logger) + } + + /** Initializes logging during [premain] and also logs the log directory. */ + @Throws(IOException::class) + private fun initializeLogging(agentOptions: AgentOptions, logger: DelayedLogger) { + if (agentOptions.isDebugLogging) { + initializeDebugLogging(agentOptions, logger) + } else { + loggingResources = LoggingUtils.initializeLogging(agentOptions.getLoggingConfig()) + logger.info("Logging to ${LogDirectoryPropertyDefiner().getPropertyValue()}") + } + + if (agentOptions.teamscaleServerOptions.isConfiguredForServerConnection) { + if (LogToTeamscaleAppender.addTeamscaleAppenderTo(LoggingUtils.loggerContext, agentOptions)) { + logger.info("Logs are being forwarded to Teamscale at ${agentOptions.teamscaleServerOptions.url}") + } + } + } + + /** Closes the opened logging contexts. */ + fun closeLoggingResources() { + loggingResources.close() + } + + /** + * Returns in instance of the agent that was configured. Either an agent with interval based line-coverage dump or + * the HTTP server is used. + */ + @Throws(UploaderException::class, IOException::class) + private fun createAgent( + agentOptions: AgentOptions, + instrumentation: Instrumentation? + ): AgentBase = if (agentOptions.useTestwiseCoverageMode()) { + TestwiseCoverageAgent.create(agentOptions) + } else { + Agent(agentOptions, instrumentation) + } + + /** + * Initializes debug logging during [.premain] and also logs the log directory if + * given. + */ + private fun initializeDebugLogging(agentOptions: AgentOptions, logger: DelayedLogger) { + loggingResources = LoggingUtils.initializeDebugLogging(agentOptions.getDebugLogDirectory()) + val logDirectory = Paths.get(DebugLogDirectoryPropertyDefiner().getPropertyValue()) + if (FileSystemUtils.isValidPath(logDirectory.toString()) && Files.isWritable(logDirectory)) { + logger.info("Logging to $logDirectory") + } else { + logger.warn("Could not create $logDirectory. Logging to console only.") + } + } + + /** + * Initializes fallback logging in case of an error during the parsing of the options to + * [premain] (see TS-23151). This tries to extract the logging configuration and use + * this and falls back to the default logger. + */ + private fun initializeFallbackLogging( + premainOptions: String?, + delayedLogger: DelayedLogger + ): LoggingUtils.LoggingResources? { + if (premainOptions == null) { + return LoggingUtils.initializeDefaultLogging() + } + premainOptions + .split(",".toRegex()) + .dropLastWhile { it.isEmpty() } + .forEach { optionPart -> + if (optionPart.startsWith(AgentOptionsParser.DEBUG + "=")) { + val value = optionPart.split("=".toRegex(), limit = 2)[1] + val debugDisabled = value.equals("false", ignoreCase = true) + val debugEnabled = value.equals("true", ignoreCase = true) + if (debugDisabled) return@forEach + var debugLogDirectory: Path? = null + if (!value.isEmpty() && !debugEnabled) { + debugLogDirectory = Paths.get(value) + } + return LoggingUtils.initializeDebugLogging(debugLogDirectory) + } + if (optionPart.startsWith(AgentOptionsParser.LOGGING_CONFIG_OPTION + "=")) { + return createFallbackLoggerFromConfig( + optionPart.split("=".toRegex(), limit = 2)[1], + delayedLogger + ) + } + + if (optionPart.startsWith(AgentOptionsParser.CONFIG_FILE_OPTION + "=")) { + val configFileValue = optionPart.split("=".toRegex(), limit = 2)[1] + var loggingConfigLine: String? = null + try { + val configFile = FilePatternResolver(delayedLogger).parsePath( + AgentOptionsParser.CONFIG_FILE_OPTION, configFileValue + ).toFile() + loggingConfigLine = FileSystemUtils.readLinesUTF8(configFile) + .firstOrNull { it.startsWith(AgentOptionsParser.LOGGING_CONFIG_OPTION + "=") } + } catch (e: IOException) { + delayedLogger.error("Failed to load configuration from $configFileValue: ${e.message}", e) + } + loggingConfigLine?.let { config -> + return createFallbackLoggerFromConfig( + config.split("=".toRegex(), limit = 2)[1], delayedLogger + ) + } + } + } + + return LoggingUtils.initializeDefaultLogging() + } + + /** Creates a fallback logger using the given config file. */ + private fun createFallbackLoggerFromConfig( + configLocation: String, + delayedLogger: ILogger + ): LoggingUtils.LoggingResources { + try { + return LoggingUtils.initializeLogging( + FilePatternResolver(delayedLogger).parsePath( + AgentOptionsParser.LOGGING_CONFIG_OPTION, + configLocation + ) + ) + } catch (e: IOException) { + val message = "Failed to load log configuration from location $configLocation: ${e.message}" + delayedLogger.error(message, e) + // output the message to console as well, as this might + // otherwise not make it to the user + System.err.println(message) + return LoggingUtils.initializeDefaultLogging() + } + } +} \ No newline at end of file diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/ResourceBase.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/ResourceBase.kt new file mode 100644 index 000000000..fd4bb77cc --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/ResourceBase.kt @@ -0,0 +1,141 @@ +package com.teamscale.jacoco.agent + +import com.teamscale.client.CommitDescriptor +import com.teamscale.client.StringUtils +import com.teamscale.client.TeamscaleServer +import com.teamscale.jacoco.agent.logging.LoggingUtils +import com.teamscale.report.testwise.model.RevisionInfo +import org.jetbrains.annotations.Contract +import org.slf4j.Logger +import java.util.Optional +import javax.ws.rs.BadRequestException +import javax.ws.rs.GET +import javax.ws.rs.PUT +import javax.ws.rs.Path +import javax.ws.rs.Produces +import javax.ws.rs.core.MediaType +import javax.ws.rs.core.Response + +/** + * The resource of the Jersey + Jetty http server holding all the endpoints specific for the [AgentBase]. + */ +abstract class ResourceBase { + /** The logger. */ + @JvmField + protected val logger: Logger = LoggingUtils.getLogger(this) + + companion object { + /** + * The agentBase inject via [AgentResource.setAgent] or + * [com.teamscale.jacoco.agent.testimpact.TestwiseCoverageResource.setAgent]. + */ + @JvmStatic + protected lateinit var agentBase: AgentBase + } + + @get:Path("/partition") + @get:GET + val partition: String + /** Returns the partition for the Teamscale upload. */ + get() = agentBase.options.teamscaleServerOptions.partition.orEmpty() + + @get:Path("/message") + @get:GET + val message: String + /** Returns the upload message for the Teamscale upload. */ + get() = agentBase.options.teamscaleServerOptions.message.orEmpty() + + @get:Produces(MediaType.APPLICATION_JSON) + @get:Path("/revision") + @get:GET + val revision: RevisionInfo + /** Returns revision information for the Teamscale upload. */ + get() = revisionInfo + + @get:Produces(MediaType.APPLICATION_JSON) + @get:Path("/commit") + @get:GET + val commit: RevisionInfo + /** Returns revision information for the Teamscale upload. */ + get() = revisionInfo + + /** Handles setting the partition name. */ + @PUT + @Path("/partition") + fun setPartition(partitionString: String): Response { + val partition = StringUtils.removeDoubleQuotes(partitionString) + if (partition.isEmpty()) { + handleBadRequest("The new partition name is missing in the request body! Please add it as plain text.") + } + + logger.debug("Changing partition name to $partition") + agentBase.dumpReport() + agentBase.controller.sessionId = partition + agentBase.options.teamscaleServerOptions.partition = partition + return Response.noContent().build() + } + + /** Handles setting the upload message. */ + @PUT + @Path("/message") + fun setMessage(messageString: String): Response { + val message = StringUtils.removeDoubleQuotes(messageString) + if (message.isEmpty()) { + handleBadRequest("The new message is missing in the request body! Please add it as plain text.") + } + + agentBase.dumpReport() + logger.debug("Changing message to $message") + agentBase.options.teamscaleServerOptions.message = message + + return Response.noContent().build() + } + + /** Handles setting the revision. */ + @PUT + @Path("/revision") + fun setRevision(revisionString: String): Response { + val revision = StringUtils.removeDoubleQuotes(revisionString) + if (revision.isEmpty()) { + handleBadRequest("The new revision name is missing in the request body! Please add it as plain text.") + } + + agentBase.dumpReport() + logger.debug("Changing revision name to $revision") + agentBase.options.teamscaleServerOptions.revision = revision + + return Response.noContent().build() + } + + /** Handles setting the upload commit. */ + @PUT + @Path("/commit") + fun setCommit(commitString: String): Response { + val commit = StringUtils.removeDoubleQuotes(commitString) + if (commit.isEmpty()) { + handleBadRequest("The new upload commit is missing in the request body! Please add it as plain text.") + } + + agentBase.dumpReport() + agentBase.options.teamscaleServerOptions.commit = CommitDescriptor.parse(commit) + + return Response.noContent().build() + } + + private val revisionInfo: RevisionInfo + /** Returns revision information for the Teamscale upload. */ + get() { + val server = agentBase.options.teamscaleServerOptions + return RevisionInfo(server.commit, server.revision) + } + + /** + * Handles bad requests to the endpoints. + */ + @Contract(value = "_ -> fail") + @Throws(BadRequestException::class) + protected fun handleBadRequest(message: String?) { + logger.error(message) + throw BadRequestException(message) + } +} \ No newline at end of file diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/commandline/ICommand.java b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commandline/ICommand.kt similarity index 77% rename from agent/src/main/java/com/teamscale/jacoco/agent/commandline/ICommand.java rename to agent/src/main/kotlin/com/teamscale/jacoco/agent/commandline/ICommand.kt index 4ce2fa697..5bbc9b7cd 100644 --- a/agent/src/main/java/com/teamscale/jacoco/agent/commandline/ICommand.java +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commandline/ICommand.kt @@ -3,27 +3,26 @@ | Copyright (c) 2009-2017 CQSE GmbH | | | +-------------------------------------------------------------------------*/ -package com.teamscale.jacoco.agent.commandline; +package com.teamscale.jacoco.agent.commandline -import com.teamscale.jacoco.agent.options.AgentOptionParseException; - -import java.io.IOException; +import com.teamscale.jacoco.agent.options.AgentOptionParseException +import java.io.IOException /** * Interface for commands: argument parsing and execution. */ -public interface ICommand { - +interface ICommand { /** * Makes sure the arguments are valid. Must return all detected problems in the * form of a user-visible message. */ - Validator validate() throws AgentOptionParseException, IOException; + @Throws(AgentOptionParseException::class, IOException::class) + fun validate(): Validator /** * Runs the implementation of the command. May throw an exception to indicate * abnormal termination of the program. */ - void run() throws Exception; - + @Throws(Exception::class) + fun run() } \ No newline at end of file diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/commandline/Validator.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commandline/Validator.kt new file mode 100644 index 000000000..6f520dc7d --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commandline/Validator.kt @@ -0,0 +1,61 @@ +/*-------------------------------------------------------------------------+ +| | +| Copyright (c) 2009-2017 CQSE GmbH | +| | ++-------------------------------------------------------------------------*/ +package com.teamscale.jacoco.agent.commandline + +import com.teamscale.client.StringUtils +import com.teamscale.jacoco.agent.util.Assertions + +/** + * Helper class to allow for multiple validations to occur. + */ +class Validator { + /** The found validation problems in the form of error messages for the user. */ + private val messages = mutableListOf() + + /** Runs the given validation routine. */ + fun ensure(validation: ExceptionBasedValidation) { + try { + validation.validate() + } catch (e: Exception) { + e.message?.let { messages.add(it) } + } catch (e: AssertionError) { + e.message?.let { messages.add(it) } + } + } + + /** + * Interface for a validation routine that throws an exception when it fails. + */ + fun interface ExceptionBasedValidation { + /** + * Throws an [Exception] or [AssertionError] if the validation fails. + */ + @Throws(Exception::class, AssertionError::class) + fun validate() + } + + /** + * Checks that the given condition is `true` or adds the given error message. + */ + fun isTrue(condition: Boolean, message: String?) { + ensure { Assertions.isTrue(condition, message) } + } + + /** + * Checks that the given condition is `false` or adds the given error message. + */ + fun isFalse(condition: Boolean, message: String?) { + ensure { Assertions.isFalse(condition, message) } + } + + val isValid: Boolean + /** Returns `true` if the validation succeeded. */ + get() = messages.isEmpty() + + val errorMessage: String + /** Returns an error message with all validation problems that were found. */ + get() = "- ${messages.joinToString("${StringUtils.LINE_FEED}- ")}" +} diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/CommitInfo.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/CommitInfo.kt new file mode 100644 index 000000000..d66014435 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/CommitInfo.kt @@ -0,0 +1,28 @@ +package com.teamscale.jacoco.agent.commit_resolution.git_properties + +import com.teamscale.client.CommitDescriptor +import com.teamscale.client.StringUtils.isEmpty +import java.util.* + +/** Hold information regarding a commit. */ +data class CommitInfo( + /** The revision information (git hash). */ + @JvmField var revision: String?, + /** The commit descriptor. */ + @JvmField var commit: CommitDescriptor? +) { + /** + * If the commit property is set via the `teamscale.commit.branch` and `teamscale.commit.time` + * properties in a git.properties file, this should be preferred to the revision. For details see [TS-38561](https://cqse.atlassian.net/browse/TS-38561). + */ + @JvmField + var preferCommitDescriptorOverRevision: Boolean = false + + override fun toString() = "$commit/$revision" + + /** + * Returns true if one of or both, revision and commit, are set + */ + val isEmpty: Boolean + get() = revision.isNullOrEmpty() && commit == null +} diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitMultiProjectPropertiesLocator.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitMultiProjectPropertiesLocator.kt new file mode 100644 index 000000000..935272d8e --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitMultiProjectPropertiesLocator.kt @@ -0,0 +1,95 @@ +package com.teamscale.jacoco.agent.commit_resolution.git_properties + +import com.teamscale.jacoco.agent.logging.LoggingUtils.getLogger +import com.teamscale.jacoco.agent.upload.teamscale.DelayedTeamscaleMultiProjectUploader +import com.teamscale.jacoco.agent.util.DaemonThreadFactory +import org.jetbrains.annotations.VisibleForTesting +import java.io.File +import java.io.IOException +import java.time.format.DateTimeFormatter +import java.util.concurrent.Executor +import java.util.concurrent.Executors + +/** + * Searches a Jar/War/Ear/... file for a git.properties file in order to enable upload for the commit described therein, + * e.g. to Teamscale, via a [DelayedTeamscaleMultiProjectUploader]. Specifically, this searches for the + * 'teamscale.project' property specified in each of the discovered 'git.properties' files. + */ +class GitMultiProjectPropertiesLocator( + private val uploader: DelayedTeamscaleMultiProjectUploader, + private val executor: Executor, + private val recursiveSearch: Boolean, + private val gitPropertiesCommitTimeFormat: DateTimeFormatter? +) : IGitPropertiesLocator { + private val logger = getLogger(this) + + constructor( + uploader: DelayedTeamscaleMultiProjectUploader, + recursiveSearch: Boolean, + gitPropertiesCommitTimeFormat: DateTimeFormatter? + ) : this( + uploader, Executors.newSingleThreadExecutor( + DaemonThreadFactory( + GitMultiProjectPropertiesLocator::class.java, + "git.properties Jar scanner thread" + ) + ), recursiveSearch, gitPropertiesCommitTimeFormat + ) + + /** + * Asynchronously searches the given jar file for git.properties files and adds a corresponding uploader to the + * multi-project uploader. + */ + override fun searchFileForGitPropertiesAsync(file: File, isJarFile: Boolean) { + executor.execute { searchFile(file, isJarFile) } + } + + /** + * Synchronously searches the given jar file for git.properties files and adds a corresponding uploader to the + * multi-project uploader. + */ + @VisibleForTesting + fun searchFile(file: File, isJarFile: Boolean) { + logger.debug("Searching file {} for multiple git.properties", file.toString()) + try { + val projectAndCommits = GitPropertiesLocatorUtils.getProjectRevisionsFromGitProperties( + file, isJarFile, recursiveSearch, gitPropertiesCommitTimeFormat + ) + if (projectAndCommits.isEmpty()) { + logger.debug("No git.properties file found in {}", file) + return + } + + projectAndCommits.forEach { projectAndCommit -> + // this code only runs when 'teamscale-project' is not given via the agent properties, + // i.e., a multi-project upload is being attempted. + // Therefore, we expect to find both the project (teamscale.project) and the revision + // (git.commit.id) in the git.properties file. + if (projectAndCommit.project == null || projectAndCommit.commitInfo == null) { + logger.debug( + "Found inconsistent git.properties file: the git.properties file in {} either does not specify the" + + " Teamscale project ({}) property, or does not specify the commit " + + "({}, {} + {}, or {} + {})." + + " Will skip this git.properties file and try to continue with the other ones that were found during discovery.", + file, GitPropertiesLocatorUtils.GIT_PROPERTIES_TEAMSCALE_PROJECT, + GitPropertiesLocatorUtils.GIT_PROPERTIES_GIT_COMMIT_ID, + GitPropertiesLocatorUtils.GIT_PROPERTIES_GIT_BRANCH, + GitPropertiesLocatorUtils.GIT_PROPERTIES_GIT_COMMIT_TIME, + GitPropertiesLocatorUtils.GIT_PROPERTIES_TEAMSCALE_COMMIT_BRANCH, + GitPropertiesLocatorUtils.GIT_PROPERTIES_TEAMSCALE_COMMIT_TIME + ) + return@forEach + } + logger.debug( + "Found git.properties file in {} and found Teamscale project {} and revision {}", file, + projectAndCommit.project, projectAndCommit.commitInfo + ) + uploader.addTeamscaleProjectAndCommit(file, projectAndCommit) + } + } catch (e: IOException) { + logger.error("Error during asynchronous search for git.properties in {}", file, e) + } catch (e: InvalidGitPropertiesException) { + logger.error("Error during asynchronous search for git.properties in {}", file, e) + } + } +} diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitPropertiesLocatingTransformer.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitPropertiesLocatingTransformer.kt new file mode 100644 index 000000000..107f63a93 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitPropertiesLocatingTransformer.kt @@ -0,0 +1,80 @@ +package com.teamscale.jacoco.agent.commit_resolution.git_properties + +import com.teamscale.jacoco.agent.logging.LoggingUtils.getLogger +import com.teamscale.report.util.ClasspathWildcardIncludeFilter +import java.io.File +import java.lang.instrument.ClassFileTransformer +import java.security.ProtectionDomain +import java.util.concurrent.ConcurrentSkipListSet + +/** + * [ClassFileTransformer] that doesn't change the loaded classes but searches their corresponding Jar/War/Ear/... + * files for a git.properties file. + */ +class GitPropertiesLocatingTransformer( + private val locator: IGitPropertiesLocator, + private val locationIncludeFilter: ClasspathWildcardIncludeFilter +) : ClassFileTransformer { + private val logger = getLogger(this) + private val seenJars = ConcurrentSkipListSet() + + override fun transform( + classLoader: ClassLoader?, + className: String, + aClass: Class<*>?, + protectionDomain: ProtectionDomain?, + classFileContent: ByteArray? + ): ByteArray? { + if (protectionDomain == null) { + // happens for e.g. java.lang. We can ignore these classes + return null + } + + if (className.isEmpty() || !locationIncludeFilter.isIncluded(className)) { + // only search in jar files of included classes + return null + } + + try { + val codeSource = protectionDomain.codeSource + if (codeSource == null || codeSource.location == null) { + // unknown when this can happen, we suspect when code is generated at runtime + // but there's nothing else we can do here in either case. + // codeSource.getLocation() is null e.g. when executing Pixelitor with Java14 for class sun/reflect/misc/Trampoline + logger.debug( + "Could not locate code source for class {}. Skipping git.properties search for this class", + className + ) + return null + } + + val jarOrClassFolderUrl = codeSource.location + val searchRoot = GitPropertiesLocatorUtils.extractGitPropertiesSearchRoot( + jarOrClassFolderUrl + ) + if (searchRoot == null || searchRoot.first == null) { + logger.warn( + "Not searching location for git.properties with unknown protocol or extension {}." + + " If this location contains your git.properties, please report this warning as a" + + " bug to CQSE. In that case, auto-discovery of git.properties will not work.", + jarOrClassFolderUrl + ) + return null + } + + if (hasLocationAlreadyBeenSearched(searchRoot.first!!)) { + return null + } + + logger.debug("Scheduling asynchronous search for git.properties in {}", searchRoot) + locator.searchFileForGitPropertiesAsync(searchRoot.first, searchRoot.second!!) + } catch (e: Throwable) { + // we catch Throwable to be sure that we log all errors as anything thrown from this method is + // silently discarded by the JVM + logger.error("Failed to process class {} in search of git.properties", className, e) + } + return null + } + + private fun hasLocationAlreadyBeenSearched(location: File) = !seenJars.add(location.toString()) +} diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitPropertiesLocatorUtils.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitPropertiesLocatorUtils.kt new file mode 100644 index 000000000..78a5d7ffb --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitPropertiesLocatorUtils.kt @@ -0,0 +1,511 @@ +package com.teamscale.jacoco.agent.commit_resolution.git_properties + +import com.teamscale.client.CommitDescriptor +import com.teamscale.client.FileSystemUtils.listFilesRecursively +import com.teamscale.client.StringUtils.endsWithOneOf +import com.teamscale.client.StringUtils.isEmpty +import com.teamscale.jacoco.agent.options.ProjectAndCommit +import com.teamscale.report.util.BashFileSkippingInputStream +import java.io.File +import java.io.IOException +import java.lang.reflect.InvocationTargetException +import java.net.URISyntaxException +import java.net.URL +import java.nio.file.Files +import java.nio.file.Paths +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.time.format.DateTimeFormatterBuilder +import java.time.format.DateTimeParseException +import java.util.* +import java.util.jar.JarEntry +import java.util.jar.JarInputStream +import java.util.regex.Pattern + +/** Utility methods to extract certain properties from git.properties files in archives and folders. */ +object GitPropertiesLocatorUtils { + /** Name of the git.properties file. */ + const val GIT_PROPERTIES_FILE_NAME: String = "git.properties" + + /** The git.properties key that holds the commit time. */ + const val GIT_PROPERTIES_GIT_COMMIT_TIME: String = "git.commit.time" + + /** The git.properties key that holds the commit branch. */ + const val GIT_PROPERTIES_GIT_BRANCH: String = "git.branch" + + /** The git.properties key that holds the commit hash. */ + const val GIT_PROPERTIES_GIT_COMMIT_ID: String = "git.commit.id" + + /** + * Alternative git.properties key that might also hold the commit hash, depending on the Maven git-commit-id plugin + * configuration. + */ + const val GIT_PROPERTIES_GIT_COMMIT_ID_FULL: String = "git.commit.id.full" + + /** + * You can provide a teamscale timestamp in git.properties files to overwrite the revision. See [TS-38561](https://cqse.atlassian.net/browse/TS-38561). + */ + const val GIT_PROPERTIES_TEAMSCALE_COMMIT_BRANCH: String = "teamscale.commit.branch" + + /** + * You can provide a teamscale timestamp in git.properties files to overwrite the revision. See [TS-38561](https://cqse.atlassian.net/browse/TS-38561). + */ + const val GIT_PROPERTIES_TEAMSCALE_COMMIT_TIME: String = "teamscale.commit.time" + + /** The git.properties key that holds the Teamscale project name. */ + const val GIT_PROPERTIES_TEAMSCALE_PROJECT: String = "teamscale.project" + + /** Matches the path to the jar file in a jar:file: URL in regex group 1. */ + private val JAR_URL_REGEX: Pattern = Pattern.compile( + "jar:(?:file|nested):(.*?)!.*", + Pattern.CASE_INSENSITIVE + ) + + private val NESTED_JAR_REGEX: Pattern = Pattern.compile( + "[jwea]ar:file:(.*?)\\*(.*)", + Pattern.CASE_INSENSITIVE + ) + + /** + * Defined in [GitCommitIdMojo](https://github.com/git-commit-id/git-commit-id-maven-plugin/blob/ac05b16dfdcc2aebfa45ad3af4acf1254accffa3/src/main/java/pl/project13/maven/git/GitCommitIdMojo.java#L522) + */ + private const val GIT_PROPERTIES_DEFAULT_MAVEN_DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ssXXX" + + /** + * Defined in [GitPropertiesPlugin](https://github.com/n0mer/gradle-git-properties/blob/bb1c3353bb570495644b6c6c75e211296a8354fc/src/main/groovy/com/gorylenko/GitPropertiesPlugin.groovy#L68) + */ + private const val GIT_PROPERTIES_DEFAULT_GRADLE_DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ssZ" + + /** + * Reads the git SHA1 and branch and timestamp from the given jar file's git.properties and builds a commit + * descriptor out of it. If no git.properties file can be found, returns null. + * + * @throws IOException If reading the jar file fails. + * @throws InvalidGitPropertiesException If a git.properties file is found but it is malformed. + */ + @JvmStatic + @Throws(IOException::class, InvalidGitPropertiesException::class) + fun getCommitInfoFromGitProperties( + file: File, + isJarFile: Boolean, + recursiveSearch: Boolean, + gitPropertiesCommitTimeFormat: DateTimeFormatter? + ) = findGitPropertiesInFile(file, isJarFile, recursiveSearch).map { entryWithProperties -> + getCommitInfoFromGitProperties( + entryWithProperties.second, entryWithProperties.first, file, + gitPropertiesCommitTimeFormat + ) + } + + /** + * Tries to extract a file system path to a search root for the git.properties search. A search root is either a + * file system folder or a Jar file. If no such path can be extracted, returns null. + * + * @throws URISyntaxException under certain circumstances if parsing the URL fails. This should be treated the same + * as a null search result but the exception is preserved so it can be logged. + */ + @JvmStatic + @Throws( + URISyntaxException::class, + IOException::class, + NoSuchMethodException::class, + IllegalAccessException::class, + InvocationTargetException::class + ) + fun extractGitPropertiesSearchRoot( + jarOrClassFolderUrl: URL + ): Pair? { + val protocol = jarOrClassFolderUrl.protocol.lowercase(Locale.getDefault()) + when (protocol) { + "file" -> { + val jarOrClassFolderFile = File(jarOrClassFolderUrl.toURI()) + if (jarOrClassFolderFile.isDirectory() || isJarLikeFile(jarOrClassFolderUrl.path)) { + return jarOrClassFolderFile to !jarOrClassFolderFile.isDirectory() + } + } + + "jar" -> { + // Used e.g. by Spring Boot. Example: jar:file:/home/k/demo.jar!/BOOT-INF/classes!/ + val jarMatcher = JAR_URL_REGEX.matcher(jarOrClassFolderUrl.toString()) + if (jarMatcher.matches()) { + return File(jarMatcher.group(1)) to true + } + // Used by some web applications and potentially fat jars. + // Example: war:file:/Users/example/apache-tomcat/webapps/demo.war*/WEB-INF/lib/demoLib-1.0-SNAPSHOT.jar + val nestedMatcher = NESTED_JAR_REGEX.matcher(jarOrClassFolderUrl.toString()) + if (nestedMatcher.matches()) { + return File(nestedMatcher.group(1)) to true + } + } + + "war", "ear" -> { + val nestedMatcher = NESTED_JAR_REGEX.matcher(jarOrClassFolderUrl.toString()) + if (nestedMatcher.matches()) { + return File(nestedMatcher.group(1)) to true + } + } + + "vfs" -> return getVfsContentFolder(jarOrClassFolderUrl) + else -> return null + } + return null + } + + /** + * VFS (Virtual File System) protocol is used by JBoss EAP and Wildfly. Example of an URL: + * vfs:/content/helloworld.war/WEB-INF/classes + */ + @Throws( + IOException::class, + NoSuchMethodException::class, + IllegalAccessException::class, + InvocationTargetException::class + ) + private fun getVfsContentFolder( + jarOrClassFolderUrl: URL + ): Pair { + // we obtain the URL of a specific class file as input, e.g., + // vfs:/content/helloworld.war/WEB-INF/classes + // Next, we try to extract the artefact URL from it, e.g., vfs:/content/helloworld.war + val artefactUrl = extractArtefactUrl(jarOrClassFolderUrl) + + val virtualFile = URL(artefactUrl).openConnection().getContent() + val virtualFileClass: Class<*> = virtualFile.javaClass + // obtain the physical location of the class file. It is created on demand in /standalone/tmp/vfs + val getPhysicalFileMethod = virtualFileClass.getMethod("getPhysicalFile") + val file = getPhysicalFileMethod.invoke(virtualFile) as File + return file to !file.isDirectory() + } + + /** + * Extracts the artefact URL (e.g., vfs:/content/helloworld.war/) from the full URL of the class file (e.g., + * vfs:/content/helloworld.war/WEB-INF/classes). + */ + private fun extractArtefactUrl(jarOrClassFolderUrl: URL): String { + val url = jarOrClassFolderUrl.path.lowercase(Locale.getDefault()) + val pathSegments = url.split("/".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + val artefactUrlBuilder = StringBuilder("vfs:") + var segmentIdx = 0 + while (segmentIdx < pathSegments.size) { + val segment = pathSegments[segmentIdx] + artefactUrlBuilder.append(segment) + artefactUrlBuilder.append("/") + if (isJarLikeFile(segment)) { + break + } + segmentIdx += 1 + } + if (segmentIdx == pathSegments.size) { + return url + } + return artefactUrlBuilder.toString() + } + + private fun isJarLikeFile(segment: String) = endsWithOneOf( + segment.lowercase(Locale.getDefault()), ".jar", ".war", ".ear", ".aar" + ) + + /** + * Reads the 'teamscale.project' property values and the git SHA1s or branch + timestamp from all git.properties + * files contained in the provided folder or archive file. + * + * @throws IOException If reading the jar file fails. + * @throws InvalidGitPropertiesException If a git.properties file is found but it is malformed. + */ + @JvmStatic + @Throws(IOException::class, InvalidGitPropertiesException::class) + fun getProjectRevisionsFromGitProperties( + file: File, isJarFile: Boolean, recursiveSearch: Boolean, + gitPropertiesCommitTimeFormat: DateTimeFormatter? + ) = findGitPropertiesInFile( + file, isJarFile, + recursiveSearch + ).map { entryWithProperties -> + val commitInfo = getCommitInfoFromGitProperties( + entryWithProperties.second, + entryWithProperties.first, file, gitPropertiesCommitTimeFormat + ) + val project = entryWithProperties.second.getProperty(GIT_PROPERTIES_TEAMSCALE_PROJECT) + if (commitInfo.isEmpty && isEmpty(project)) { + throw InvalidGitPropertiesException( + "No entry or empty value for both '$GIT_PROPERTIES_GIT_COMMIT_ID'/'$GIT_PROPERTIES_GIT_COMMIT_ID_FULL' and '$GIT_PROPERTIES_TEAMSCALE_PROJECT' in $file.\nContents of $GIT_PROPERTIES_FILE_NAME: ${entryWithProperties.second}" + ) + } + ProjectAndCommit(project, commitInfo) + } + + /** + * Returns pairs of paths to git.properties files and their parsed properties found in the provided folder or + * archive file. Nested jar files will also be searched recursively if specified. + */ + @JvmStatic + @Throws(IOException::class) + fun findGitPropertiesInFile( + file: File, isJarFile: Boolean, recursiveSearch: Boolean + ): List> { + if (isJarFile) { + return findGitPropertiesInArchiveFile(file, recursiveSearch) + } + return findGitPropertiesInDirectoryFile(file, recursiveSearch) + } + + /** + * Searches for git properties in jar/war/ear/aar files + */ + @Throws(IOException::class) + private fun findGitPropertiesInArchiveFile( + file: File, + recursiveSearch: Boolean + ): List> { + try { + JarInputStream( + BashFileSkippingInputStream(Files.newInputStream(file.toPath())) + ).use { jarStream -> + return findGitPropertiesInArchive(jarStream, file.getName(), recursiveSearch) + } + } catch (e: IOException) { + throw IOException( + "Reading jar ${file.absolutePath} for obtaining commit descriptor from git.properties failed", e + ) + } + } + + /** + * Searches for git.properties file in the given folder + * + * @param recursiveSearch If enabled, git.properties files will also be searched in jar files + */ + @Throws(IOException::class) + private fun findGitPropertiesInDirectoryFile( + directoryFile: File, recursiveSearch: Boolean + ): List> { + val result = findGitPropertiesInFolder(directoryFile).toMutableList() + + if (recursiveSearch) { + result.addAll(findGitPropertiesInNestedJarFiles(directoryFile)) + } + + return result.toList() + } + + /** + * Finds all jar files in the given folder and searches them recursively for git.properties + */ + @Throws(IOException::class) + private fun findGitPropertiesInNestedJarFiles(directoryFile: File) = + listFilesRecursively(directoryFile) { + isJarLikeFile(it.getName()) + }.flatMap { jarFile -> + val inputStream = JarInputStream(Files.newInputStream(jarFile.toPath())) + val relativeFilePath = "${directoryFile.getName()}${File.separator}" + directoryFile.toPath() + .relativize(jarFile.toPath()) + findGitPropertiesInArchive(inputStream, relativeFilePath, true) + } + + /** + * Searches for git.properties files in the given folder + */ + @Throws(IOException::class) + private fun findGitPropertiesInFolder(directoryFile: File) = + listFilesRecursively(directoryFile) { + it.getName().equals(GIT_PROPERTIES_FILE_NAME, ignoreCase = true) + }.map { gitPropertiesFile -> + try { + Files.newInputStream(gitPropertiesFile.toPath()).use { inputStream -> + val gitProperties = Properties() + gitProperties.load(inputStream) + val relativeFilePath = "${directoryFile.getName()}${File.separator}" + directoryFile.toPath() + .relativize(gitPropertiesFile.toPath()) + relativeFilePath to gitProperties + } + } catch (e: IOException) { + throw IOException( + "Reading directory ${gitPropertiesFile.absolutePath} for obtaining commit descriptor from git.properties failed", e + ) + } + } + + /** + * Returns pairs of paths to git.properties files and their parsed properties found in the provided JarInputStream. + * Nested jar files will also be searched recursively if specified. + */ + @JvmStatic + @JvmOverloads + @Throws(IOException::class) + fun findGitPropertiesInArchive( + inputStream: JarInputStream, + archiveName: String?, + recursiveSearch: Boolean, + isRootArchive: Boolean = true // Added flag to prevent nested crashes + ): MutableList> { + val result = mutableListOf>() + var isEmpty = true + + var entry = inputStream.nextJarEntry + while (entry != null) { + isEmpty = false + val fullEntryName = if (archiveName.isNullOrEmpty()) entry.name else "$archiveName/${entry.name}" + val fileName = entry.name.substringAfterLast('/') + + if (fileName.equals(GIT_PROPERTIES_FILE_NAME, ignoreCase = true)) { + val gitProperties = Properties().apply { load(inputStream) } + result.add(fullEntryName to gitProperties) + + } else if (recursiveSearch && isJarLikeFile(entry.name)) { + val nestedJarStream = JarInputStream(inputStream) + result.addAll( + findGitPropertiesInArchive(nestedJarStream, fullEntryName, + recursiveSearch = true, + isRootArchive = false + ) + ) + } + entry = inputStream.nextJarEntry + } + + if (isEmpty && isRootArchive) { + throw IOException("No entries in Jar file $archiveName. Is this a valid jar file?. If so, please report to CQSE.") + } + + return result + } + + /** + * Returns the CommitInfo (revision and branch + timestmap) from a git properties file. The revision can be either + * in [.GIT_PROPERTIES_GIT_COMMIT_ID] or [.GIT_PROPERTIES_GIT_COMMIT_ID_FULL]. The branch and timestamp + * in [.GIT_PROPERTIES_GIT_BRANCH] + [.GIT_PROPERTIES_GIT_COMMIT_TIME] or in + * [.GIT_PROPERTIES_TEAMSCALE_COMMIT_BRANCH] + [.GIT_PROPERTIES_TEAMSCALE_COMMIT_TIME]. By default, + * times will be parsed with [.GIT_PROPERTIES_DEFAULT_GRADLE_DATE_FORMAT] and + * [.GIT_PROPERTIES_DEFAULT_MAVEN_DATE_FORMAT]. An additional format can be given with + * `dateTimeFormatter` + */ + @JvmStatic + @Throws(InvalidGitPropertiesException::class) + fun getCommitInfoFromGitProperties( + gitProperties: Properties, entryName: String?, jarFile: File?, + additionalDateTimeFormatter: DateTimeFormatter? + ): CommitInfo { + val dateTimeFormatter = createDateTimeFormatter(additionalDateTimeFormatter) + + // Get Revision + val revision = getRevisionFromGitProperties(gitProperties) + + // Get branch and timestamp from git.commit.branch and git.commit.id + var commitDescriptor = getCommitDescriptorFromDefaultGitPropertyValues( + gitProperties, entryName, + jarFile, dateTimeFormatter + ) + // When read from these properties, we should prefer to upload to the revision + var preferCommitDescriptorOverRevision = false + + + // Get branch and timestamp from teamscale.commit.branch and teamscale.commit.time (TS-38561) + val teamscaleTimestampBasedCommitDescriptor = getCommitDescriptorFromTeamscaleTimestampProperty( + gitProperties, entryName, jarFile, dateTimeFormatter + ) + if (teamscaleTimestampBasedCommitDescriptor != null) { + // In this case, as we specifically set this property, we should prefer branch and timestamp to the revision + preferCommitDescriptorOverRevision = true + commitDescriptor = teamscaleTimestampBasedCommitDescriptor + } + + if (isEmpty(revision) && commitDescriptor == null) { + throw InvalidGitPropertiesException( + "No entry or invalid value for '" + GIT_PROPERTIES_GIT_COMMIT_ID + "', '" + GIT_PROPERTIES_GIT_COMMIT_ID_FULL + + "', '" + GIT_PROPERTIES_TEAMSCALE_COMMIT_BRANCH + "' and " + GIT_PROPERTIES_TEAMSCALE_COMMIT_TIME + "'\n" + + "Location: Entry '" + entryName + "' in jar file '" + jarFile + "'." + + "\nContents of " + GIT_PROPERTIES_FILE_NAME + ":\n" + gitProperties + ) + } + + val commitInfo = CommitInfo(revision, commitDescriptor) + commitInfo.preferCommitDescriptorOverRevision = preferCommitDescriptorOverRevision + return commitInfo + } + + private fun createDateTimeFormatter( + additionalDateTimeFormatter: DateTimeFormatter? + ): DateTimeFormatter { + val defaultDateTimeFormatter = DateTimeFormatter.ofPattern( + String.format( + "[%s][%s]", GIT_PROPERTIES_DEFAULT_MAVEN_DATE_FORMAT, + GIT_PROPERTIES_DEFAULT_GRADLE_DATE_FORMAT + ) + ) + val builder = DateTimeFormatterBuilder().append(defaultDateTimeFormatter) + if (additionalDateTimeFormatter != null) { + builder.append(additionalDateTimeFormatter) + } + return builder.toFormatter() + } + + private fun getRevisionFromGitProperties(gitProperties: Properties): String? { + var revision = gitProperties.getProperty(GIT_PROPERTIES_GIT_COMMIT_ID) + if (isEmpty(revision)) { + revision = gitProperties.getProperty(GIT_PROPERTIES_GIT_COMMIT_ID_FULL) + } + return revision + } + + @Throws(InvalidGitPropertiesException::class) + private fun getCommitDescriptorFromTeamscaleTimestampProperty( + gitProperties: Properties, + entryName: String?, + jarFile: File?, + dateTimeFormatter: DateTimeFormatter + ): CommitDescriptor? { + val teamscaleCommitBranch = gitProperties.getProperty(GIT_PROPERTIES_TEAMSCALE_COMMIT_BRANCH) + val teamscaleCommitTime = gitProperties.getProperty(GIT_PROPERTIES_TEAMSCALE_COMMIT_TIME) + + if (isEmpty(teamscaleCommitBranch) || isEmpty(teamscaleCommitTime)) { + return null + } + + val teamscaleTimestampRegex = "\\d*(?:p\\d*)?" + val teamscaleTimestampMatcher = Pattern.compile(teamscaleTimestampRegex).matcher(teamscaleCommitTime) + if (teamscaleTimestampMatcher.matches()) { + return CommitDescriptor(teamscaleCommitBranch, teamscaleCommitTime) + } + + val epochTimestamp: Long + try { + epochTimestamp = ZonedDateTime.parse(teamscaleCommitTime, dateTimeFormatter).toInstant().toEpochMilli() + } catch (e: DateTimeParseException) { + throw InvalidGitPropertiesException( + ("Cannot parse commit time '" + teamscaleCommitTime + "' in the '" + GIT_PROPERTIES_TEAMSCALE_COMMIT_TIME + + "' property. It needs to be in the date formats '" + GIT_PROPERTIES_DEFAULT_MAVEN_DATE_FORMAT + + "' or '" + GIT_PROPERTIES_DEFAULT_GRADLE_DATE_FORMAT + "' or match the Teamscale timestamp format '" + + teamscaleTimestampRegex + "'." + + "\nLocation: Entry '" + entryName + "' in jar file '" + jarFile + "'." + + "\nContents of " + GIT_PROPERTIES_FILE_NAME + ":\n" + gitProperties), e + ) + } + + return CommitDescriptor(teamscaleCommitBranch, epochTimestamp) + } + + @Throws(InvalidGitPropertiesException::class) + private fun getCommitDescriptorFromDefaultGitPropertyValues( + gitProperties: Properties, + entryName: String?, + jarFile: File?, + dateTimeFormatter: DateTimeFormatter + ): CommitDescriptor? { + val gitBranch = gitProperties.getProperty(GIT_PROPERTIES_GIT_BRANCH) + val gitTime = gitProperties.getProperty(GIT_PROPERTIES_GIT_COMMIT_TIME) + if (!isEmpty(gitBranch) && !isEmpty(gitTime)) { + val gitTimestamp: Long + try { + gitTimestamp = ZonedDateTime.parse(gitTime, dateTimeFormatter).toInstant().toEpochMilli() + } catch (e: DateTimeParseException) { + throw InvalidGitPropertiesException( + "Could not parse the timestamp in property '" + GIT_PROPERTIES_GIT_COMMIT_TIME + "'." + + "\nLocation: Entry '" + entryName + "' in jar file '" + jarFile + "'." + + "\nContents of " + GIT_PROPERTIES_FILE_NAME + ":\n" + gitProperties, e + ) + } + return CommitDescriptor(gitBranch, gitTimestamp) + } + return null + } +} diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitSingleProjectPropertiesLocator.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitSingleProjectPropertiesLocator.kt new file mode 100644 index 000000000..170a60ae1 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitSingleProjectPropertiesLocator.kt @@ -0,0 +1,104 @@ +package com.teamscale.jacoco.agent.commit_resolution.git_properties + +import com.teamscale.jacoco.agent.logging.LoggingUtils.getLogger +import com.teamscale.jacoco.agent.upload.delay.DelayedUploader +import com.teamscale.jacoco.agent.util.DaemonThreadFactory +import java.io.File +import java.io.IOException +import java.time.format.DateTimeFormatter +import java.util.concurrent.Executor +import java.util.concurrent.Executors + +/** + * Searches a Jar/War/Ear/... file for a git.properties file in order to enable upload for the commit described therein, + * e.g. to Teamscale, via a [DelayedUploader]. + */ +class GitSingleProjectPropertiesLocator( + private val uploader: DelayedUploader, + private val dataExtractor: DataExtractor, + private val executor: Executor, + private val recursiveSearch: Boolean, + private val gitPropertiesCommitTimeFormat: DateTimeFormatter? +) : IGitPropertiesLocator { + private val logger = getLogger(this) + private var foundData: T? = null + private var jarFileWithGitProperties: File? = null + + constructor( + uploader: DelayedUploader, + dataExtractor: DataExtractor, + recursiveSearch: Boolean, + gitPropertiesCommitTimeFormat: DateTimeFormatter? + ) : this( + uploader, dataExtractor, Executors.newSingleThreadExecutor( + DaemonThreadFactory( + GitSingleProjectPropertiesLocator::class.java, + "git.properties Jar scanner thread" + ) + ), recursiveSearch, gitPropertiesCommitTimeFormat + ) + + /** + * Asynchronously searches the given jar file for a git.properties file. + */ + override fun searchFileForGitPropertiesAsync(file: File, isJarFile: Boolean) { + executor.execute { searchFile(file, isJarFile) } + } + + private fun searchFile(file: File, isJarFile: Boolean) { + logger.debug("Searching jar file {} for a single git.properties", file) + try { + val data = dataExtractor.extractData(file, isJarFile, recursiveSearch, gitPropertiesCommitTimeFormat) + if (data.isEmpty()) { + logger.debug("No git.properties files found in {}", file.toString()) + return + } + if (data.size > 1) { + logger.warn( + "Multiple git.properties files found in {}", file.toString() + + ". Using the first one: " + data.first() + ) + } + val dataEntry = data.first() + + if (foundData != null) { + if (foundData != dataEntry) { + logger.warn( + "Found inconsistent git.properties files: {} contained data {} while {} contained {}." + + " Please ensure that all git.properties files of your application are consistent." + + " Otherwise, you may" + + " be uploading to the wrong project/commit which will result in incorrect coverage data" + + " displayed in Teamscale. If you cannot fix the inconsistency, you can manually" + + " specify a Jar/War/Ear/... file from which to read the correct git.properties" + + " file with the agent's teamscale-git-properties-jar parameter.", + jarFileWithGitProperties, foundData, file, data + ) + } + return + } + + logger.debug( + "Found git.properties file in {} and found commit descriptor {}", file.toString(), + dataEntry + ) + foundData = dataEntry + jarFileWithGitProperties = file + uploader.setCommitAndTriggerAsynchronousUpload(dataEntry) + } catch (e: IOException) { + logger.error("Error during asynchronous search for git.properties in {}", file.toString(), e) + } catch (e: InvalidGitPropertiesException) { + logger.error("Error during asynchronous search for git.properties in {}", file.toString(), e) + } + } + + /** Functional interface for data extraction from a jar file. */ + fun interface DataExtractor { + /** Extracts data from the JAR. */ + @Throws(IOException::class, InvalidGitPropertiesException::class) + fun extractData( + file: File?, isJarFile: Boolean, + recursiveSearch: Boolean, + gitPropertiesCommitTimeFormat: DateTimeFormatter? + ): MutableList + } +} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/IGitPropertiesLocator.java b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/IGitPropertiesLocator.kt similarity index 61% rename from agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/IGitPropertiesLocator.java rename to agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/IGitPropertiesLocator.kt index 0fc60c7ce..30f724b69 100644 --- a/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/IGitPropertiesLocator.java +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/IGitPropertiesLocator.kt @@ -1,13 +1,12 @@ -package com.teamscale.jacoco.agent.commit_resolution.git_properties; +package com.teamscale.jacoco.agent.commit_resolution.git_properties -import java.io.File; - -/** Interface for the locator classes that search files (e.g., a JAR) for git.properties files containing certain properties. */ -public interface IGitPropertiesLocator { +import java.io.File +/** Interface for the locator classes that search files (e.g., a JAR) for git.properties files containing certain properties. */ +interface IGitPropertiesLocator { /** * Searches the file for the git.properties file containing certain properties. The boolean flag indicates whether the * searched file is a JAR file or a plain directory. */ - void searchFileForGitPropertiesAsync(File file, boolean isJarFile); + fun searchFileForGitPropertiesAsync(file: File, isJarFile: Boolean) } diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/InvalidGitPropertiesException.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/InvalidGitPropertiesException.kt new file mode 100644 index 000000000..bbfb2e59e --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/InvalidGitPropertiesException.kt @@ -0,0 +1,9 @@ +package com.teamscale.jacoco.agent.commit_resolution.git_properties + +/** + * Thrown in case a git.properties file is found but it is malformed. + */ +class InvalidGitPropertiesException : Exception { + internal constructor(s: String, throwable: Throwable?) : super(s, throwable) + constructor(s: String) : super(s) +} diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/sapnwdi/NwdiMarkerClassLocatingTransformer.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/sapnwdi/NwdiMarkerClassLocatingTransformer.kt new file mode 100644 index 000000000..b06ac4a18 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/sapnwdi/NwdiMarkerClassLocatingTransformer.kt @@ -0,0 +1,82 @@ +package com.teamscale.jacoco.agent.commit_resolution.sapnwdi + +import com.teamscale.client.CommitDescriptor +import com.teamscale.client.StringUtils.isEmpty +import com.teamscale.jacoco.agent.logging.LoggingUtils.getLogger +import com.teamscale.jacoco.agent.options.sapnwdi.DelayedSapNwdiMultiUploader +import com.teamscale.jacoco.agent.options.sapnwdi.SapNwdiApplication +import com.teamscale.report.util.ClasspathWildcardIncludeFilter +import java.lang.instrument.ClassFileTransformer +import java.nio.file.Files +import java.nio.file.Paths +import java.nio.file.attribute.BasicFileAttributes +import java.security.ProtectionDomain +import java.util.function.Function +import java.util.stream.Collectors + +/** + * [ClassFileTransformer] that doesn't change the loaded classes but guesses the rough commit timestamp by + * inspecting the last modification date of the applications marker class file. + */ +class NwdiMarkerClassLocatingTransformer( + private val store: DelayedSapNwdiMultiUploader, + private val locationIncludeFilter: ClasspathWildcardIncludeFilter, + apps: MutableCollection +) : ClassFileTransformer { + private val logger = getLogger(this) + private val markerClassesToApplications = + apps.associateBy { it.getMarkerClass().replace('.', '/') } + + override fun transform( + classLoader: ClassLoader?, + className: String, + aClass: Class<*>?, + protectionDomain: ProtectionDomain?, + classFileContent: ByteArray? + ): ByteArray? { + if (protectionDomain == null) { + // happens for e.g. java.lang. We can ignore these classes + return null + } + + if (className.isEmpty() || !locationIncludeFilter.isIncluded(className)) { + // only search in jar files of included classes + return null + } + + if (!markerClassesToApplications.containsKey(className)) { + // only kick off search if the marker class was found. + return null + } + + try { + // unknown when this can happen, we suspect when code is generated at runtime + // but there's nothing else we can do here in either case + val codeSource = protectionDomain.codeSource ?: return null + + val jarOrClassFolderUrl = codeSource.location + logger.debug("Found {} in {}", className, jarOrClassFolderUrl) + + if (jarOrClassFolderUrl.protocol.equals("file", ignoreCase = true)) { + val file = Paths.get(jarOrClassFolderUrl.toURI()) + val attr = Files.readAttributes(file, BasicFileAttributes::class.java) + val commitDescriptor = CommitDescriptor( + DTR_BRIDGE_DEFAULT_BRANCH, attr.lastModifiedTime().toMillis() + ) + store.setCommitForApplication(commitDescriptor, markerClassesToApplications[className]) + } + } catch (e: Throwable) { + // we catch Throwable to be sure that we log all errors as anything thrown from this method is + // silently discarded by the JVM + logger.error( + "Failed to process class {} trying to determine its last modification timestamp.", className, e + ) + } + return null + } + + companion object { + /** The Design time repository-git-bridge (DTR-bridge) currently only exports a single branch named master. */ + private const val DTR_BRIDGE_DEFAULT_BRANCH = "master" + } +} diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/configuration/AgentOptionReceiveException.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/configuration/AgentOptionReceiveException.kt new file mode 100644 index 000000000..40c0a3104 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/configuration/AgentOptionReceiveException.kt @@ -0,0 +1,7 @@ +package com.teamscale.jacoco.agent.configuration + +/** Thrown when retrieving the profiler configuration from Teamscale fails. */ +class AgentOptionReceiveException : Exception { + constructor(message: String?) : super(message) + constructor(message: String?, cause: Throwable?) : super(message, cause) +} \ No newline at end of file diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/configuration/ConfigurationViaTeamscale.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/configuration/ConfigurationViaTeamscale.kt new file mode 100644 index 000000000..f20312b13 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/configuration/ConfigurationViaTeamscale.kt @@ -0,0 +1,162 @@ +package com.teamscale.jacoco.agent.configuration + +import com.fasterxml.jackson.core.JsonProcessingException +import com.teamscale.client.ITeamscaleService +import com.teamscale.client.JsonUtils +import com.teamscale.client.ProcessInformation +import com.teamscale.client.ProfilerConfiguration +import com.teamscale.client.ProfilerInfo +import com.teamscale.client.ProfilerRegistration +import com.teamscale.client.TeamscaleServiceGenerator +import com.teamscale.jacoco.agent.logging.LoggingUtils +import com.teamscale.jacoco.agent.util.AgentUtils +import com.teamscale.report.util.ILogger +import okhttp3.HttpUrl +import okhttp3.ResponseBody +import retrofit2.Response +import java.io.IOException +import java.time.Duration +import java.util.concurrent.Executors +import java.util.concurrent.ThreadFactory +import java.util.concurrent.TimeUnit + +/** + * Responsible for holding the configuration retrieved from Teamscale and sending regular heartbeat events to + * keep the profiler information in Teamscale up to date. + */ +class ConfigurationViaTeamscale( + private val teamscaleClient: ITeamscaleService, + profilerRegistration: ProfilerRegistration, + processInformation: ProcessInformation +) { + /** + * The UUID that Teamscale assigned to this instance of the profiler during the registration. This ID needs to be + * used when communicating with Teamscale. + */ + @JvmField + val profilerId = profilerRegistration.profilerId + + private val profilerInfo = ProfilerInfo(processInformation, profilerRegistration.profilerConfiguration) + + /** Returns the profiler configuration retrieved from Teamscale. */ + val profilerConfiguration: ProfilerConfiguration? + get() = profilerInfo.profilerConfiguration + + /** + * Starts a heartbeat thread and registers a shutdown hook. + * + * + * This spawns a new thread every minute which sends a heartbeat to Teamscale. It also registers a shutdown hook + * that unregisters the profiler from Teamscale. + */ + fun startHeartbeatThreadAndRegisterShutdownHook() { + val executor = Executors.newSingleThreadScheduledExecutor { runnable -> + val thread = Thread(runnable) + thread.setDaemon(true) + thread + } + + executor.scheduleAtFixedRate({ sendHeartbeat() }, 1, 1, TimeUnit.MINUTES) + + Runtime.getRuntime().addShutdownHook(Thread { + executor.shutdownNow() + unregisterProfiler() + }) + } + + private fun sendHeartbeat() { + try { + val response = teamscaleClient.sendHeartbeat(profilerId!!, profilerInfo).execute() + if (!response.isSuccessful) { + LoggingUtils.getLogger(this) + .error("Failed to send heartbeat. Teamscale responded with: ${response.errorBody()?.string()}") + } + } catch (e: IOException) { + LoggingUtils.getLogger(this).error("Failed to send heartbeat to Teamscale!", e) + } + } + + /** Unregisters the profiler in Teamscale (marks it as shut down). */ + fun unregisterProfiler() { + try { + var response = teamscaleClient.unregisterProfiler(profilerId!!).execute() + if (response.code() == 405) { + response = teamscaleClient.unregisterProfilerLegacy(profilerId).execute() + } + if (!response.isSuccessful) { + LoggingUtils.getLogger(this) + .error("Failed to unregister profiler. Teamscale responded with: ${response.errorBody()?.string()}") + } + } catch (e: IOException) { + LoggingUtils.getLogger(this).error("Failed to unregister profiler!", e) + } + } + + companion object { + /** + * Two minute timeout. This is quite high to account for an eventual high load on the Teamscale server. This is a + * tradeoff between fast application startup and potentially missing test coverage. + */ + private val LONG_TIMEOUT: Duration = Duration.ofMinutes(2) + + /** + * Tries to retrieve the profiler configuration from Teamscale. In case retrieval fails the method throws a + * [AgentOptionReceiveException]. + */ + @JvmStatic + @Throws(AgentOptionReceiveException::class) + fun retrieve( + logger: ILogger, + configurationId: String?, + url: HttpUrl, + userName: String, + userAccessToken: String + ): ConfigurationViaTeamscale { + val teamscaleClient = TeamscaleServiceGenerator + .createService(url, userName, userAccessToken, AgentUtils.USER_AGENT, LONG_TIMEOUT, LONG_TIMEOUT) + try { + val processInformation = ProcessInformationRetriever(logger).processInformation + val response = teamscaleClient.registerProfiler( + configurationId, + processInformation + ).execute() + if (!response.isSuccessful) { + throw AgentOptionReceiveException( + "Failed to retrieve profiler configuration from Teamscale due to failed request. Http status: ${response.code()} Body: ${response.errorBody()?.string()}" + ) + } + + val body = response.body() + return parseProfilerRegistration(body!!, response, teamscaleClient, processInformation) + } catch (e: IOException) { + // we include the causing error message in this exception's message since this causes it to be printed + // to stderr which is much more helpful than just saying "something didn't work" + throw AgentOptionReceiveException( + "Failed to retrieve profiler configuration from Teamscale due to network error: ${ + LoggingUtils.getStackTraceAsString(e) + }", e + ) + } + } + + @Throws(AgentOptionReceiveException::class, IOException::class) + private fun parseProfilerRegistration( + body: ResponseBody, + response: Response, + teamscaleClient: ITeamscaleService, + processInformation: ProcessInformation + ): ConfigurationViaTeamscale { + // We may only call this once + val bodyString = body.string() + try { + val registration = JsonUtils.deserialize(bodyString) + return ConfigurationViaTeamscale(teamscaleClient, registration, processInformation) + } catch (e: JsonProcessingException) { + throw AgentOptionReceiveException( + "Failed to retrieve profiler configuration from Teamscale due to invalid JSON. HTTP code: " + response.code() + " Response: " + bodyString, + e + ) + } + } + } +} \ No newline at end of file diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/configuration/ProcessInformationRetriever.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/configuration/ProcessInformationRetriever.kt new file mode 100644 index 000000000..e4692acdd --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/configuration/ProcessInformationRetriever.kt @@ -0,0 +1,53 @@ +package com.teamscale.jacoco.agent.configuration + +import com.teamscale.client.ProcessInformation +import com.teamscale.report.util.ILogger +import java.lang.management.ManagementFactory +import java.net.InetAddress +import java.net.UnknownHostException + +/** + * Is responsible for retrieving process information such as the host name and process ID. + */ +class ProcessInformationRetriever(private val logger: ILogger) { + /** + * Retrieves the process information, including the host name and process ID. + */ + val processInformation: ProcessInformation + get() = ProcessInformation(hostName, pID, System.currentTimeMillis()) + + /** + * Retrieves the host name of the local machine. + */ + private val hostName: String + get() { + try { + return InetAddress.getLocalHost().hostName + } catch (e: UnknownHostException) { + logger.error("Failed to determine hostname!", e) + return "" + } + } + + /** + * Returns a string that *probably* contains the PID. + * + * On Java 9 there is an API to get the PID. But since we support Java 8, we may fall back to an undocumented API + * that at least contains the PID in most JVMs. + * + * See [This StackOverflow question](https://stackoverflow.com/questions/35842/how-can-a-java-program-get-its-own-process-id) + */ + companion object { + val pID: String + get() { + try { + val processHandleClass = Class.forName("java.lang.ProcessHandle") + val processHandle = processHandleClass.getMethod("current").invoke(null) + val pid = processHandleClass.getMethod("pid").invoke(processHandle) as Long + return pid.toString() + } catch (_: ReflectiveOperationException) { + return ManagementFactory.getRuntimeMXBean().name + } + } + } +} \ No newline at end of file diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/convert/ConvertCommand.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/convert/ConvertCommand.kt new file mode 100644 index 000000000..2183de6f5 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/convert/ConvertCommand.kt @@ -0,0 +1,156 @@ +/*-------------------------------------------------------------------------+ +| | +| Copyright (c) 2009-2017 CQSE GmbH | +| | ++-------------------------------------------------------------------------*/ +package com.teamscale.jacoco.agent.convert + +import com.beust.jcommander.Parameter +import com.beust.jcommander.Parameters +import com.teamscale.client.FileSystemUtils.ensureDirectoryExists +import com.teamscale.client.StringUtils.isEmpty +import com.teamscale.jacoco.agent.commandline.ICommand +import com.teamscale.jacoco.agent.commandline.Validator +import com.teamscale.jacoco.agent.options.ClasspathUtils +import com.teamscale.jacoco.agent.options.FilePatternResolver +import com.teamscale.jacoco.agent.util.Assertions +import com.teamscale.report.EDuplicateClassFileBehavior +import com.teamscale.report.util.CommandLineLogger +import java.io.File +import java.io.IOException + +/** + * Encapsulates all command line options for the convert command for parsing with [JCommander]. + */ +@Parameters( + commandNames = ["convert"], commandDescription = "Converts a binary .exec coverage file to XML. " + + "Note that the XML report will only contain source file coverage information, but no class coverage." +) +class ConvertCommand : ICommand { + /** The directories and/or zips that contain all class files being profiled. */ + @JvmField + @Parameter( + names = ["--class-dir", "--jar", "-c"], required = true, description = ("" + + "The directories or zip/ear/jar/war/... files that contain the compiled Java classes being profiled." + + " Searches recursively, including inside zips. You may also supply a *.txt file with one path per line.") + ) + var classDirectoriesOrZips = mutableListOf() + + /** + * Wildcard include patterns to apply during JaCoCo's traversal of class files. + */ + @Parameter( + names = ["--includes"], description = ("" + + "Wildcard include patterns to apply to all found class file locations during JaCoCo's traversal of class files." + + " Note that zip contents are separated from zip files with @ and that you can filter only" + + " class files, not intermediate folders/zips. Use with great care as missing class files" + + " lead to broken coverage files! Turn on debug logging to see which locations are being filtered." + + " Defaults to no filtering. Excludes overrule includes.") + ) + var locationIncludeFilters = mutableListOf() + + /** + * Wildcard exclude patterns to apply during JaCoCo's traversal of class files. + */ + @Parameter( + names = ["--excludes", "-e"], description = ("" + + "Wildcard exclude patterns to apply to all found class file locations during JaCoCo's traversal of class files." + + " Note that zip contents are separated from zip files with @ and that you can filter only" + + " class files, not intermediate folders/zips. Use with great care as missing class files" + + " lead to broken coverage files! Turn on debug logging to see which locations are being filtered." + + " Defaults to no filtering. Excludes overrule includes.") + ) + var locationExcludeFilters = mutableListOf() + + /** The directory to write the XML traces to. */ + @JvmField + @Parameter( + names = ["--in", "-i"], required = true, description = ("" + "The binary .exec file(s), test details and " + + "test executions to read. Can be a single file or a directory that is recursively scanned for relevant files.") + ) + var inputFiles = mutableListOf() + + /** The directory to write the XML traces to. */ + @JvmField + @Parameter( + names = ["--out", "-o"], required = true, description = ("" + + "The file to write the generated XML report to.") + ) + var outputFile = "" + + /** Whether to ignore duplicate, non-identical class files. */ + @Parameter( + names = ["--duplicates", "-d"], arity = 1, description = ("" + + "Whether to ignore duplicate, non-identical class files." + + " This is discouraged and may result in incorrect coverage files. Defaults to WARN. " + + "Options are FAIL, WARN and IGNORE.") + ) + var duplicateClassFileBehavior = EDuplicateClassFileBehavior.WARN + + /** Whether to ignore uncovered class files. */ + @Parameter( + names = ["--ignore-uncovered-classes"], required = false, arity = 1, description = ("" + + "Whether to ignore uncovered classes." + + " These classes will not be part of the XML report at all, making it considerably smaller in some cases. Defaults to false.") + ) + var shouldIgnoreUncoveredClasses = false + + /** Whether testwise coverage or jacoco coverage should be generated. */ + @Parameter( + names = ["--testwise-coverage", "-t"], required = false, arity = 0, description = "Whether testwise " + + "coverage or jacoco coverage should be generated." + ) + var shouldGenerateTestwiseCoverage = false + + /** After how many tests testwise coverage should be split into multiple reports. */ + @Parameter( + names = ["--split-after", "-s"], required = false, arity = 1, description = "After how many tests " + + "testwise coverage should be split into multiple reports (Default is 5000)." + ) + val splitAfter = 5000 + + @Throws(IOException::class) + fun getClassDirectoriesOrZips(): List = ClasspathUtils + .resolveClasspathTextFiles( + "class-dir", FilePatternResolver(CommandLineLogger()), + classDirectoriesOrZips + ) + + fun getInputFiles() = inputFiles.map { File(it) } + fun getOutputFile() = File(outputFile) + + /** Makes sure the arguments are valid. */ + override fun validate() = Validator().apply { + val classDirectoriesOrZips = mutableListOf() + ensure { classDirectoriesOrZips.addAll(getClassDirectoriesOrZips()) } + isFalse( + classDirectoriesOrZips.isEmpty(), + "You must specify at least one directory or zip that contains class files" + ) + classDirectoriesOrZips.forEach { path -> + isTrue(path.exists(), "Path '$path' does not exist") + isTrue(path.canRead(), "Path '$path' is not readable") + } + getInputFiles().forEach { inputFile -> + isTrue(inputFile.exists() && inputFile.canRead(), "Cannot read the input file $inputFile") + } + ensure { + Assertions.isFalse(isEmpty(outputFile), "You must specify an output file") + val outputDir = getOutputFile().getAbsoluteFile().getParentFile() + ensureDirectoryExists(outputDir) + Assertions.isTrue(outputDir.canWrite(), "Path '$outputDir' is not writable") + } + } + + /** {@inheritDoc} */ + @Throws(Exception::class) + override fun run() { + Converter(this).apply { + if (shouldGenerateTestwiseCoverage) { + runTestwiseCoverageReportGeneration() + } else { + runJaCoCoReportGeneration() + } + } + } +} diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/convert/Converter.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/convert/Converter.kt new file mode 100644 index 000000000..0506d8acf --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/convert/Converter.kt @@ -0,0 +1,99 @@ +package com.teamscale.jacoco.agent.convert + +import com.teamscale.client.TestDetails +import com.teamscale.jacoco.agent.benchmark +import com.teamscale.jacoco.agent.logging.LoggingUtils +import com.teamscale.jacoco.agent.options.AgentOptionParseException +import com.teamscale.jacoco.agent.util.Benchmark +import com.teamscale.report.ReportUtils +import com.teamscale.report.ReportUtils.listFiles +import com.teamscale.report.jacoco.EmptyReportException +import com.teamscale.report.jacoco.JaCoCoXmlReportGenerator +import com.teamscale.report.testwise.ETestArtifactFormat +import com.teamscale.report.testwise.TestwiseCoverageReportWriter +import com.teamscale.report.testwise.jacoco.JaCoCoTestwiseReportGenerator +import com.teamscale.report.testwise.model.TestExecution +import com.teamscale.report.testwise.model.factory.TestInfoFactory +import com.teamscale.report.util.ClasspathWildcardIncludeFilter +import com.teamscale.report.util.CommandLineLogger +import com.teamscale.report.util.ILogger +import java.io.File +import java.io.IOException +import java.lang.String +import java.nio.file.Paths +import kotlin.Array +import kotlin.Throws +import kotlin.use + +/** Converts one .exec binary coverage file to XML. */ +class Converter +/** Constructor. */( + /** The command line arguments. */ + private val arguments: ConvertCommand +) { + /** Converts one .exec binary coverage file to XML. */ + @Throws(IOException::class) + fun runJaCoCoReportGeneration() { + val logger = LoggingUtils.getLogger(this) + val generator = JaCoCoXmlReportGenerator( + arguments.getClassDirectoriesOrZips(), + wildcardIncludeExcludeFilter, + arguments.duplicateClassFileBehavior, + arguments.shouldIgnoreUncoveredClasses, + LoggingUtils.wrap(logger) + ) + + val jacocoExecutionDataList = listFiles(ETestArtifactFormat.JACOCO, arguments.getInputFiles()) + try { + benchmark("Generating the XML report") { + generator.convertExecFilesToReport(jacocoExecutionDataList, Paths.get(arguments.outputFile).toFile()) + } + } catch (e: EmptyReportException) { + logger.warn("Converted report was empty.", e) + } + } + + /** Converts one .exec binary coverage file, test details and test execution files to JSON testwise coverage. */ + @Throws(IOException::class, AgentOptionParseException::class) + fun runTestwiseCoverageReportGeneration() { + val testDetails = ReportUtils.readObjects( + ETestArtifactFormat.TEST_LIST, + Array::class.java, + arguments.getInputFiles() + ) + val testExecutions = ReportUtils.readObjects( + ETestArtifactFormat.TEST_EXECUTION, + Array::class.java, + arguments.getInputFiles() + ) + + val jacocoExecutionDataList = listFiles(ETestArtifactFormat.JACOCO, arguments.getInputFiles()) + val logger = CommandLineLogger() + + val generator = JaCoCoTestwiseReportGenerator( + arguments.getClassDirectoriesOrZips(), + this.wildcardIncludeExcludeFilter, + arguments.duplicateClassFileBehavior, + logger + ) + + benchmark("Generating the testwise coverage report") { + logger.info("Writing report with ${testDetails.size} Details/${testExecutions.size} Results") + TestwiseCoverageReportWriter( + TestInfoFactory(testDetails, testExecutions), + arguments.getOutputFile(), + arguments.splitAfter, null + ).use { coverageWriter -> + jacocoExecutionDataList.forEach { executionDataFile -> + generator.convertAndConsume(executionDataFile, coverageWriter) + } + } + } + } + + private val wildcardIncludeExcludeFilter: ClasspathWildcardIncludeFilter + get() = ClasspathWildcardIncludeFilter( + String.join(":", arguments.locationIncludeFilters), + String.join(":", arguments.locationExcludeFilters) + ) +} diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/DebugLogDirectoryPropertyDefiner.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/DebugLogDirectoryPropertyDefiner.kt new file mode 100644 index 000000000..876e989f2 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/DebugLogDirectoryPropertyDefiner.kt @@ -0,0 +1,14 @@ +package com.teamscale.jacoco.agent.logging + +import java.nio.file.Path + +/** Defines a property that contains the path to which log files should be written. */ +class DebugLogDirectoryPropertyDefiner : LogDirectoryPropertyDefiner() { + override fun getPropertyValue() = + filePath?.resolve("logs")?.toAbsolutePath()?.toString() ?: super.getPropertyValue() + + companion object { + /** File path for debug logging. */ /* package */ + var filePath: Path? = null + } +} \ No newline at end of file diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/LogDirectoryPropertyDefiner.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/LogDirectoryPropertyDefiner.kt new file mode 100644 index 000000000..c587a45f0 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/LogDirectoryPropertyDefiner.kt @@ -0,0 +1,10 @@ +package com.teamscale.jacoco.agent.logging + +import ch.qos.logback.core.PropertyDefinerBase +import com.teamscale.jacoco.agent.util.AgentUtils + +/** Defines a property that contains the default path to which log files should be written. */ +open class LogDirectoryPropertyDefiner : PropertyDefinerBase() { + override fun getPropertyValue() = + AgentUtils.mainTempDirectory.resolve("logs").toAbsolutePath().toString() +} \ No newline at end of file diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/LogToTeamscaleAppender.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/LogToTeamscaleAppender.kt new file mode 100644 index 000000000..f646f9848 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/LogToTeamscaleAppender.kt @@ -0,0 +1,183 @@ +package com.teamscale.jacoco.agent.logging + +import ch.qos.logback.classic.Logger +import ch.qos.logback.classic.LoggerContext +import ch.qos.logback.classic.spi.ILoggingEvent +import ch.qos.logback.core.AppenderBase +import ch.qos.logback.core.status.ErrorStatus +import com.teamscale.client.ITeamscaleService +import com.teamscale.client.ProfilerLogEntry +import com.teamscale.jacoco.agent.options.AgentOptions +import java.net.ConnectException +import java.time.Duration +import java.util.Collections +import java.util.IdentityHashMap +import java.util.LinkedHashSet +import java.util.concurrent.CompletableFuture +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.function.BiConsumer + +/** + * Custom log appender that sends logs to Teamscale; it buffers log that were not sent due to connection issues and + * sends them later. + */ +class LogToTeamscaleAppender : AppenderBase() { + /** The unique ID of the profiler */ + private var profilerId: String? = null + + /** + * Buffer for unsent logs. We use a set here to allow for removing entries fast after sending them to Teamscale was + * successful. + */ + private val logBuffer = LinkedHashSet() + + /** Scheduler for sending logs after the configured time interval */ + private val scheduler: ScheduledExecutorService = Executors.newScheduledThreadPool(1) { r -> + // Make the thread a daemon so that it does not prevent the JVM from terminating. + val t = Executors.defaultThreadFactory().newThread(r) + t.setDaemon(true) + t + } + + /** Active log flushing threads */ + private val activeLogFlushes: MutableSet> = + Collections.newSetFromMap(IdentityHashMap()) + + /** Is there a flush going on right now? */ + private val isFlusing = AtomicBoolean(false) + + override fun start() { + super.start() + scheduler.scheduleAtFixedRate({ + synchronized(activeLogFlushes) { + activeLogFlushes.removeIf { it.isDone } + if (activeLogFlushes.isEmpty()) flush() + } + }, FLUSH_INTERVAL.toMillis(), FLUSH_INTERVAL.toMillis(), TimeUnit.MILLISECONDS) + } + + override fun append(eventObject: ILoggingEvent) { + synchronized(logBuffer) { + logBuffer.add(formatLog(eventObject)) + if (logBuffer.size >= BATCH_SIZE) flush() + } + } + + private fun formatLog(eventObject: ILoggingEvent): ProfilerLogEntry { + val trace = LoggingUtils.getStackTraceFromEvent(eventObject) + val timestamp = eventObject.timeStamp + val message = eventObject.formattedMessage + val severity = eventObject.level.toString() + return ProfilerLogEntry(timestamp, message, trace, severity) + } + + private fun flush() { + sendLogs() + } + + /** Send logs in a separate thread */ + private fun sendLogs() { + synchronized(activeLogFlushes) { + activeLogFlushes.add(CompletableFuture.runAsync { + if (isFlusing.compareAndSet(false, true)) { + try { + val client = teamscaleClient ?: return@runAsync // There might be no connection configured. + + val logsToSend: MutableList + synchronized(logBuffer) { + logsToSend = logBuffer.toMutableList() + } + + val call = client.postProfilerLog(profilerId!!, logsToSend) + val response = call.execute() + check(response.isSuccessful) { "Failed to send log: HTTP error code : ${response.code()}" } + + synchronized(logBuffer) { + // Removing the logs that have been sent after the fact. + // This handles problems with lost network connections. + logBuffer.removeAll(logsToSend.toSet()) + } + } catch (e: Exception) { + // We do not report on exceptions here. + if (e !is ConnectException) { + addStatus(ErrorStatus("Sending logs to Teamscale failed: ${e.message}", this, e)) + } + } finally { + isFlusing.set(false) + } + } + }.whenComplete(BiConsumer { _, _ -> + synchronized(activeLogFlushes) { + activeLogFlushes.removeIf { it.isDone } + } + })) + } + } + + override fun stop() { + // Already flush here once to make sure that we do not miss too much. + flush() + + scheduler.shutdown() + try { + if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) { + scheduler.shutdownNow() + } + } catch (_: InterruptedException) { + scheduler.shutdownNow() + } + + // A final flush after the scheduler has been shut down. + flush() + + // Block until all flushes are done + CompletableFuture.allOf(*activeLogFlushes.toTypedArray()).join() + + super.stop() + } + + fun setTeamscaleClient(teamscaleClient: ITeamscaleService?) { + Companion.teamscaleClient = teamscaleClient + } + + fun setProfilerId(profilerId: String) { + this.profilerId = profilerId + } + + companion object { + /** Flush the logs after N elements are in the queue */ + private const val BATCH_SIZE = 50 + + /** Flush the logs in the given time interval */ + private val FLUSH_INTERVAL: Duration = Duration.ofSeconds(3) + + /** The service client for sending logs to Teamscale */ + private var teamscaleClient: ITeamscaleService? = null + + /** + * Add the [LogToTeamscaleAppender] to the logging configuration and + * enable/start it. + */ + fun addTeamscaleAppenderTo(context: LoggerContext, agentOptions: AgentOptions): Boolean { + val client = agentOptions.createTeamscaleClient(false) + if (client == null || agentOptions.configurationViaTeamscale == null) { + return false + } + + context.getLogger(Logger.ROOT_LOGGER_NAME).apply { + val logToTeamscaleAppender = LogToTeamscaleAppender().apply { + setContext(context) + setProfilerId(agentOptions.configurationViaTeamscale.profilerId!!) + setTeamscaleClient(client.service) + start() + } + addAppender(logToTeamscaleAppender) + } + + return true + } + } +} \ No newline at end of file diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/LoggingUtils.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/LoggingUtils.kt new file mode 100644 index 000000000..0e0a244a9 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/LoggingUtils.kt @@ -0,0 +1,124 @@ +package com.teamscale.jacoco.agent.logging + +import ch.qos.logback.classic.LoggerContext +import ch.qos.logback.classic.joran.JoranConfigurator +import ch.qos.logback.classic.spi.ILoggingEvent +import ch.qos.logback.classic.spi.ThrowableProxy +import ch.qos.logback.classic.spi.ThrowableProxyUtil +import ch.qos.logback.core.joran.spi.JoranException +import ch.qos.logback.core.util.StatusPrinter +import com.teamscale.jacoco.agent.Agent +import com.teamscale.jacoco.agent.util.NullOutputStream +import com.teamscale.report.util.ILogger +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.io.FileInputStream +import java.io.IOException +import java.io.InputStream +import java.io.PrintStream +import java.lang.AutoCloseable +import java.nio.file.Path + +/** + * Helps initialize the logging framework properly. + */ +object LoggingUtils { + /** Returns a logger for the given object's class. */ + @JvmStatic + fun getLogger(obj: Any): Logger = LoggerFactory.getLogger(obj.javaClass) + + /** Returns a logger for the given class. */ + @JvmStatic + fun getLogger(obj: Class<*>): Logger = LoggerFactory.getLogger(obj) + + /** Initializes the logging to the default configured in the Jar. */ + fun initializeDefaultLogging(): LoggingResources { + val stream = Agent::class.java.getResourceAsStream("logback-default.xml") + reconfigureLoggerContext(stream) + return LoggingResources() + } + + /** + * Returns the logger context. + */ + val loggerContext: LoggerContext + get() = LoggerFactory.getILoggerFactory() as LoggerContext + + /** + * Extracts the stack trace from an ILoggingEvent using ThrowableProxyUtil. + * + * @param event the logging event containing the exception + * @return the stack trace as a String, or null if no exception is associated + */ + fun getStackTraceFromEvent(event: ILoggingEvent) = + event.throwableProxy?.let { ThrowableProxyUtil.asString(it) } + + /** + * Converts a Throwable to its stack trace as a String. + * + * @param throwable the throwable to convert + * @return the stack trace as a String + */ + @JvmStatic + fun getStackTraceAsString(throwable: Throwable?) = + throwable?.let { ThrowableProxyUtil.asString(ThrowableProxy(it)) } + + /** + * Reconfigures the logger context to use the configuration XML from the given input stream. Cf. [https://logback.qos.ch/manual/configuration.html](https://logback.qos.ch/manual/configuration.html) + */ + private fun reconfigureLoggerContext(stream: InputStream?) { + StatusPrinter.setPrintStream(PrintStream(NullOutputStream())) + try { + val configurator = JoranConfigurator() + configurator.setContext(loggerContext) + loggerContext.reset() + configurator.doConfigure(stream) + } catch (_: JoranException) { + // StatusPrinter will handle this + } + StatusPrinter.printInCaseOfErrorsOrWarnings(loggerContext) + } + + /** + * Initializes the logging from the given file. If that is `null`, uses [ ][.initializeDefaultLogging] instead. + */ + @Throws(IOException::class) + fun initializeLogging(loggingConfigFile: Path?): LoggingResources { + if (loggingConfigFile == null) { + return initializeDefaultLogging() + } + + reconfigureLoggerContext(FileInputStream(loggingConfigFile.toFile())) + return LoggingResources() + } + + /** Initializes debug logging. */ + fun initializeDebugLogging(logDirectory: Path?): LoggingResources { + if (logDirectory != null) { + DebugLogDirectoryPropertyDefiner.filePath = logDirectory + } + val stream = Agent::class.java.getResourceAsStream("logback-default-debugging.xml") + reconfigureLoggerContext(stream) + return LoggingResources() + } + + /** Wraps the given slf4j logger into an [com.teamscale.report.util.ILogger]. */ + @JvmStatic + fun wrap(logger: Logger): ILogger { + return object : ILogger { + override fun debug(message: String) = logger.debug(message) + override fun info(message: String) = logger.info(message) + override fun warn(message: String) = logger.warn(message) + override fun warn(message: String, throwable: Throwable?) = logger.warn(message, throwable) + override fun error(throwable: Throwable) = logger.error(throwable.message, throwable) + override fun error(message: String, throwable: Throwable?) = logger.error(message, throwable) + } + } + + /** Class to use with try-with-resources to close the logging framework's resources. */ + class LoggingResources : AutoCloseable { + override fun close() { + loggerContext.stop() + } + } +} \ No newline at end of file diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/ProjectAndCommit.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/ProjectAndCommit.kt new file mode 100644 index 000000000..260eb3a7c --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/ProjectAndCommit.kt @@ -0,0 +1,10 @@ +package com.teamscale.jacoco.agent.options + +import com.teamscale.jacoco.agent.commit_resolution.git_properties.CommitInfo +import java.util.* + +/** Class encapsulating the Teamscale project and git commitInfo an upload should be performed to. */ +data class ProjectAndCommit( + @JvmField val project: String?, + @JvmField val commitInfo: CommitInfo? +) diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/DelayedMultiUploaderBase.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/DelayedMultiUploaderBase.kt new file mode 100644 index 000000000..34c0cc88f --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/DelayedMultiUploaderBase.kt @@ -0,0 +1,39 @@ +package com.teamscale.jacoco.agent.upload + +import com.teamscale.jacoco.agent.logging.LoggingUtils.getLogger +import com.teamscale.report.jacoco.CoverageFile +import org.slf4j.Logger +import java.util.function.Consumer +import java.util.stream.Collectors + +/** + * Base class for wrapper uploaders that allow uploading the same coverage to + * multiple locations. + */ +abstract class DelayedMultiUploaderBase : IUploader { + @JvmField + protected val logger: Logger = getLogger(this) + + @Synchronized + override fun upload(coverageFile: CoverageFile) { + val wrappedUploaders = this.wrappedUploaders + wrappedUploaders.forEach { _ -> coverageFile.acquireReference() } + if (wrappedUploaders.isEmpty()) { + logger.warn("No commits have been found yet to which coverage should be uploaded. Discarding coverage") + } else { + wrappedUploaders.forEach { wrappedUploader -> + wrappedUploader.upload(coverageFile) + } + } + } + + override fun describe(): String { + if (!wrappedUploaders.isEmpty()) { + return wrappedUploaders.joinToString { it.describe() } + } + return "Temporary stand-in until commit is resolved" + } + + /** Returns the actual uploaders that this multiuploader wraps. */ + protected abstract val wrappedUploaders: MutableCollection +} diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/HttpZipUploaderBase.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/HttpZipUploaderBase.kt new file mode 100644 index 000000000..02129b018 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/HttpZipUploaderBase.kt @@ -0,0 +1,147 @@ +package com.teamscale.jacoco.agent.upload + +import com.teamscale.client.FileSystemUtils.readFileBinary +import com.teamscale.client.HttpUtils.createRetrofit +import com.teamscale.jacoco.agent.benchmark +import com.teamscale.jacoco.agent.logging.LoggingUtils.getLogger +import com.teamscale.jacoco.agent.util.Benchmark +import com.teamscale.report.jacoco.CoverageFile +import okhttp3.HttpUrl +import okhttp3.OkHttpClient +import okhttp3.ResponseBody +import org.slf4j.Logger +import retrofit2.Response +import retrofit2.Retrofit +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.nio.file.Files +import java.nio.file.Path +import java.util.function.Consumer +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +/** Base class for uploading the coverage zip to a provided url */ +abstract class HttpZipUploaderBase +/** Constructor. */( + /** The URL to upload to. */ + @JvmField + protected var uploadUrl: HttpUrl, + /** Additional files to include in the uploaded zip. */ + protected val additionalMetaDataFiles: MutableList, + /** The API class. */ + private val apiClass: Class +) : IUploader { + /** The logger. */ + @JvmField + protected val logger: Logger = getLogger(this) + + /** The API which performs the upload */ + protected val api: T by lazy { + val retrofit = createRetrofit( + { baseUrl(uploadUrl) }, + { configureOkHttp(this) } + ) + retrofit.create(apiClass) + } + + /** Template method to configure the OkHttp Client. */ + protected open fun configureOkHttp(builder: OkHttpClient.Builder) { + } + + /** Uploads the coverage zip to the server */ + @Throws(IOException::class, UploaderException::class) + protected abstract fun uploadCoverageZip(coverageFile: File): Response + + override fun upload(coverageFile: CoverageFile) { + try { + benchmark("Uploading report via HTTP") { + if (tryUpload(coverageFile)) { + coverageFile.delete() + } else { + logger.warn( + ("Failed to upload coverage to Teamscale. " + + "Won't delete local file {} so that the upload can automatically be retried upon profiler restart. " + + "Upload can also be retried manually."), coverageFile + ) + (this as? IUploadRetry)?.markFileForUploadRetry(coverageFile) + } + } + } catch (_: IOException) { + logger.warn("Could not delete file {} after upload", coverageFile) + } + } + + /** Performs the upload and returns `true` if successful. */ + protected fun tryUpload(coverageFile: CoverageFile): Boolean { + logger.debug("Uploading coverage to {}", uploadUrl) + + val zipFile: File + try { + zipFile = createZipFile(coverageFile) + } catch (e: IOException) { + logger.error("Failed to compile coverage zip file for upload to {}", uploadUrl, e) + return false + } + + try { + val response = uploadCoverageZip(zipFile) + if (response.isSuccessful) { + return true + } + + var errorBody = "" + if (response.errorBody() != null) { + errorBody = response.errorBody()!!.string() + } + + logger.error( + "Failed to upload coverage to {}. Request failed with error code {}. Error:\n{}", uploadUrl, + response.code(), errorBody + ) + return false + } catch (e: IOException) { + logger.error("Failed to upload coverage to {}. Probably a network problem", uploadUrl, e) + return false + } catch (e: UploaderException) { + logger.error("Failed to upload coverage to {}. The configuration is probably incorrect", uploadUrl, e) + return false + } finally { + zipFile.delete() + } + } + + /** + * Creates the zip file in the system temp directory to upload which includes the given coverage XML and all + * [.additionalMetaDataFiles]. The file is marked to be deleted on exit. + */ + @Throws(IOException::class) + private fun createZipFile(coverageFile: CoverageFile): File { + val zipFile = Files.createTempFile(coverageFile.nameWithoutExtension, ".zip").toFile() + zipFile.deleteOnExit() + FileOutputStream(zipFile).use { fileOutputStream -> + ZipOutputStream(fileOutputStream).use { zipOutputStream -> + fillZipFile(zipOutputStream, coverageFile) + return zipFile + } + } + } + + /** + * Fills the upload zip file with the given coverage XML and all [.additionalMetaDataFiles]. + */ + @Throws(IOException::class) + private fun fillZipFile(zipOutputStream: ZipOutputStream, coverageFile: CoverageFile) { + zipOutputStream.putNextEntry(ZipEntry(getZipEntryCoverageFileName(coverageFile))) + coverageFile.copyStream(zipOutputStream) + + for (additionalFile in additionalMetaDataFiles) { + zipOutputStream.putNextEntry(ZipEntry(additionalFile.fileName.toString())) + zipOutputStream.write(readFileBinary(additionalFile.toFile())) + } + } + + protected open fun getZipEntryCoverageFileName(coverageFile: CoverageFile): String { + return "coverage.xml" + } +} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/upload/IUploadRetry.java b/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/IUploadRetry.kt similarity index 53% rename from agent/src/main/java/com/teamscale/jacoco/agent/upload/IUploadRetry.java rename to agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/IUploadRetry.kt index eaf9fa9ed..d3ff3393f 100644 --- a/agent/src/main/java/com/teamscale/jacoco/agent/upload/IUploadRetry.java +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/IUploadRetry.kt @@ -1,23 +1,21 @@ -package com.teamscale.jacoco.agent.upload; +package com.teamscale.jacoco.agent.upload -import java.util.Properties; - -import com.teamscale.report.jacoco.CoverageFile; +import com.teamscale.report.jacoco.CoverageFile +import java.util.* /** * Interface for all the uploaders that support an automatic upload retry * mechanism. */ -public interface IUploadRetry { - +interface IUploadRetry { /** * Marks coverage files of unsuccessful coverage uploads so that they can be * reuploaded at next agent start. */ - void markFileForUploadRetry(CoverageFile coverageFile); + fun markFileForUploadRetry(coverageFile: CoverageFile) /** * Retries previously unsuccessful coverage uploads with the given properties. */ - void reupload(CoverageFile coverageFile, Properties properties); + fun reupload(coverageFile: CoverageFile, properties: Properties) } diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/IUploader.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/IUploader.kt new file mode 100644 index 000000000..bc85d0004 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/IUploader.kt @@ -0,0 +1,16 @@ +package com.teamscale.jacoco.agent.upload + +import com.teamscale.report.jacoco.CoverageFile + +/** Uploads coverage reports. */ +interface IUploader { + /** + * Uploads the given coverage file. If the upload was successful, the coverage + * file on disk will be deleted. Otherwise the file is left on disk and a + * warning is logged. + */ + fun upload(coverageFile: CoverageFile) + + /** Human-readable description of the uploader. */ + fun describe(): String +} diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/LocalDiskUploader.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/LocalDiskUploader.kt new file mode 100644 index 000000000..c4ebe0681 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/LocalDiskUploader.kt @@ -0,0 +1,16 @@ +package com.teamscale.jacoco.agent.upload + +import com.teamscale.report.jacoco.CoverageFile + +/** + * Dummy uploader which keeps the coverage file written by the agent on disk, + * but does not actually perform uploads. + */ +class LocalDiskUploader : IUploader { + override fun upload(coverageFile: CoverageFile) { + // Don't delete the file here. We want to store the file permanently on disk in + // case no uploader is configured. + } + + override fun describe() = "configured output directory on the local disk" +} diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/UploaderException.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/UploaderException.kt new file mode 100644 index 000000000..bd50cca8f --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/UploaderException.kt @@ -0,0 +1,32 @@ +package com.teamscale.jacoco.agent.upload + +import okhttp3.ResponseBody +import retrofit2.Response +import java.io.IOException + +/** + * Exception thrown from an uploader. Either during the upload or in the validation process. + */ +class UploaderException : Exception { + /** Constructor */ + constructor(message: String, e: Exception) : super(message, e) + + /** Constructor */ + constructor(message: String) : super(message) + + /** Constructor */ + constructor(message: String, response: Response) : super(createResponseMessage(message, response)) + + companion object { + private fun createResponseMessage(message: String, response: Response): String { + try { + val errorBodyMessage = response.errorBody()!!.string() + return "$message (${response.code()}): \n$errorBodyMessage" + } catch (_: IOException) { + return message + } catch (_: NullPointerException) { + return message + } + } + } +} \ No newline at end of file diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/artifactory/ArtifactoryConfig.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/artifactory/ArtifactoryConfig.kt new file mode 100644 index 000000000..9d42f4e6c --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/artifactory/ArtifactoryConfig.kt @@ -0,0 +1,206 @@ +package com.teamscale.jacoco.agent.upload.artifactory + +import com.teamscale.client.StringUtils.stripSuffix +import com.teamscale.jacoco.agent.commit_resolution.git_properties.CommitInfo +import com.teamscale.jacoco.agent.commit_resolution.git_properties.GitPropertiesLocatorUtils +import com.teamscale.jacoco.agent.commit_resolution.git_properties.InvalidGitPropertiesException +import com.teamscale.jacoco.agent.options.AgentOptionParseException +import com.teamscale.jacoco.agent.options.AgentOptionsParser +import com.teamscale.jacoco.agent.upload.UploaderException +import com.teamscale.jacoco.agent.upload.artifactory.ArtifactoryConfig.Companion.ARTIFACTORY_API_KEY_OPTION +import com.teamscale.jacoco.agent.upload.artifactory.ArtifactoryConfig.Companion.ARTIFACTORY_LEGACY_PATH_OPTION +import com.teamscale.jacoco.agent.upload.artifactory.ArtifactoryConfig.Companion.ARTIFACTORY_PARTITION +import com.teamscale.jacoco.agent.upload.artifactory.ArtifactoryConfig.Companion.ARTIFACTORY_PASSWORD_OPTION +import com.teamscale.jacoco.agent.upload.artifactory.ArtifactoryConfig.Companion.ARTIFACTORY_PATH_SUFFIX +import com.teamscale.jacoco.agent.upload.artifactory.ArtifactoryConfig.Companion.ARTIFACTORY_USER_OPTION +import com.teamscale.jacoco.agent.upload.artifactory.ArtifactoryConfig.Companion.ARTIFACTORY_ZIP_PATH_OPTION +import okhttp3.HttpUrl +import java.io.File +import java.io.IOException +import java.time.format.DateTimeFormatter + +/** Config necessary to upload files to an azure file storage. */ +class ArtifactoryConfig { + /** Related to [ARTIFACTORY_USER_OPTION] */ + @JvmField + var url: HttpUrl? = null + + /** Related to [ARTIFACTORY_USER_OPTION] */ + @JvmField + var user: String? = null + + /** Related to [ARTIFACTORY_PASSWORD_OPTION] */ + @JvmField + var password: String? = null + + /** Related to [ARTIFACTORY_LEGACY_PATH_OPTION] */ + var legacyPath: Boolean = false + + /** Related to [ARTIFACTORY_ZIP_PATH_OPTION] */ + var zipPath: String? = null + + /** Related to [ARTIFACTORY_PATH_SUFFIX] */ + var pathSuffix: String? = null + + /** The information regarding a commit. */ + @JvmField + var commitInfo: CommitInfo? = null + + /** Related to [ARTIFACTORY_API_KEY_OPTION] */ + @JvmField + var apiKey: String? = null + + /** Related to [ARTIFACTORY_PARTITION] */ + @JvmField + var partition: String? = null + + /** Checks if all required options are set to upload to artifactory. */ + fun hasAllRequiredFieldsSet(): Boolean { + val requiredAuthOptionsSet = (user != null && password != null) || apiKey != null + val partitionSet = partition != null || legacyPath + return url != null && partitionSet && requiredAuthOptionsSet + } + + /** Checks if all required fields are null. */ + fun hasAllRequiredFieldsNull() = url == null && user == null && password == null && apiKey == null && partition == null + + /** Checks whether commit and revision are set. */ + fun hasCommitInfo() = commitInfo != null + + companion object { + /** + * Option to specify the artifactory URL. This shall be the entire path down to the directory to which the coverage + * should be uploaded to, not only the base url of artifactory. + */ + const val ARTIFACTORY_URL_OPTION: String = "artifactory-url" + + /** + * Username that shall be used for basic auth. Alternative to basic auth is to use an API key with the + * [ARTIFACTORY_API_KEY_OPTION] + */ + const val ARTIFACTORY_USER_OPTION: String = "artifactory-user" + + /** + * Password that shall be used for basic auth. Alternative to basic auth is to use an API key with the + * [ARTIFACTORY_API_KEY_OPTION] + */ + const val ARTIFACTORY_PASSWORD_OPTION: String = "artifactory-password" + + /** + * API key that shall be used to authenticate requests to artifactory with the + * [ArtifactoryUploader.ARTIFACTORY_API_HEADER]. Alternatively + * basic auth with username ([ARTIFACTORY_USER_OPTION]) and password + * ([ARTIFACTORY_PASSWORD_OPTION]) can be used. + */ + const val ARTIFACTORY_API_KEY_OPTION: String = "artifactory-api-key" + + /** + * Option that specifies if the legacy path for uploading files to artifactory should be used instead of the new + * standard path. + */ + const val ARTIFACTORY_LEGACY_PATH_OPTION: String = "artifactory-legacy-path" + + /** + * Option that specifies under which path the coverage file shall lie within the zip file that is created for the + * upload. + */ + const val ARTIFACTORY_ZIP_PATH_OPTION: String = "artifactory-zip-path" + + /** + * Option that specifies intermediate directories which should be appended. + */ + const val ARTIFACTORY_PATH_SUFFIX: String = "artifactory-path-suffix" + + /** + * Specifies the location of the JAR file which includes the git.properties file. + */ + const val ARTIFACTORY_GIT_PROPERTIES_JAR_OPTION: String = "artifactory-git-properties-jar" + + /** + * Specifies the date format in which the commit timestamp in the git.properties file is formatted. + */ + const val ARTIFACTORY_GIT_PROPERTIES_COMMIT_DATE_FORMAT_OPTION: String = + "artifactory-git-properties-commit-date-format" + + /** + * Specifies the partition for which the upload is. + */ + const val ARTIFACTORY_PARTITION: String = "artifactory-partition" + + /** + * Handles all command-line options prefixed with 'artifactory-' + * + * @return true if it has successfully processed the given option. + */ + @JvmStatic + @Throws(AgentOptionParseException::class) + fun handleArtifactoryOptions(options: ArtifactoryConfig, key: String, value: String): Boolean { + when (key) { + ARTIFACTORY_URL_OPTION -> { + options.url = AgentOptionsParser.parseUrl(key, value) + return true + } + ARTIFACTORY_USER_OPTION -> { + options.user = value + return true + } + ARTIFACTORY_PASSWORD_OPTION -> { + options.password = value + return true + } + ARTIFACTORY_LEGACY_PATH_OPTION -> { + options.legacyPath = value.toBoolean() + return true + } + ARTIFACTORY_ZIP_PATH_OPTION -> { + options.zipPath = stripSuffix(value, "/") + return true + } + ARTIFACTORY_PATH_SUFFIX -> { + options.pathSuffix = stripSuffix(value, "/") + return true + } + ARTIFACTORY_API_KEY_OPTION -> { + options.apiKey = value + return true + } + ARTIFACTORY_PARTITION -> { + options.partition = value + return true + } + else -> return false + } + } + + /** Parses the commit information form a git.properties file. */ + @JvmStatic + @Throws(UploaderException::class) + fun parseGitProperties( + jarFile: File, searchRecursively: Boolean, gitPropertiesCommitTimeFormat: DateTimeFormatter? + ): CommitInfo? { + try { + val commitInfo = GitPropertiesLocatorUtils.getCommitInfoFromGitProperties( + jarFile, + true, + searchRecursively, + gitPropertiesCommitTimeFormat + ) + if (commitInfo.isEmpty()) { + throw UploaderException("Found no git.properties files in $jarFile") + } + if (commitInfo.size > 1) { + throw UploaderException( + ("Found multiple git.properties files in " + jarFile + + ". Uploading to multiple projects is currently not possible with Artifactory. " + + "Please contact CQSE if you need this feature.") + ) + } + return commitInfo.firstOrNull() + } catch (e: IOException) { + throw UploaderException("Could not locate a valid git.properties file in $jarFile", e) + } catch (e: InvalidGitPropertiesException) { + throw UploaderException("Could not locate a valid git.properties file in $jarFile", e) + } + } + } +} diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/artifactory/ArtifactoryUploader.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/artifactory/ArtifactoryUploader.kt new file mode 100644 index 000000000..885ba65d4 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/artifactory/ArtifactoryUploader.kt @@ -0,0 +1,145 @@ +package com.teamscale.jacoco.agent.upload.artifactory + +import com.teamscale.client.CommitDescriptor.Companion.parse +import com.teamscale.client.EReportFormat +import com.teamscale.client.FileSystemUtils.normalizeSeparators +import com.teamscale.client.FileSystemUtils.replaceFilePathFilenameWith +import com.teamscale.client.HttpUtils.getBasicAuthInterceptor +import com.teamscale.client.StringUtils.emptyToNull +import com.teamscale.client.StringUtils.nullToEmpty +import com.teamscale.jacoco.agent.commit_resolution.git_properties.CommitInfo +import com.teamscale.jacoco.agent.upload.HttpZipUploaderBase +import com.teamscale.jacoco.agent.upload.IUploadRetry +import com.teamscale.jacoco.agent.upload.teamscale.ETeamscaleServerProperties +import com.teamscale.jacoco.agent.upload.teamscale.TeamscaleUploader +import com.teamscale.report.jacoco.CoverageFile +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import okhttp3.ResponseBody +import retrofit2.Response +import java.io.File +import java.io.FileWriter +import java.io.IOException +import java.nio.file.Path +import java.util.* +import kotlin.Throws + +/** + * Uploads XMLs to Artifactory. + */ +class ArtifactoryUploader( + private val artifactoryConfig: ArtifactoryConfig, + additionalMetaDataFiles: MutableList, + reportFormat: EReportFormat +) : HttpZipUploaderBase( + artifactoryConfig.url!!, + additionalMetaDataFiles, + IArtifactoryUploadApi::class.java +), IUploadRetry { + private val coverageFormat = reportFormat.name.lowercase(Locale.getDefault()) + private var uploadPath: String? = null + + override fun markFileForUploadRetry(coverageFile: CoverageFile) { + val uploadMetadataFile = File( + replaceFilePathFilenameWith( + normalizeSeparators(coverageFile.toString()), + "${coverageFile.name}${TeamscaleUploader.RETRY_UPLOAD_FILE_SUFFIX}" + ) + ) + val properties = createArtifactoryProperties() + try { + FileWriter(uploadMetadataFile).use { writer -> + properties.store(writer, null) + } + } catch (_: IOException) { + logger.warn( + "Failed to create metadata file for automatic upload retry of {}. Please manually retry the coverage upload to Azure.", + coverageFile + ) + uploadMetadataFile.delete() + } + } + + override fun reupload(coverageFile: CoverageFile, properties: Properties) { + val config = ArtifactoryConfig() + config.url = artifactoryConfig.url + config.user = artifactoryConfig.user + config.password = artifactoryConfig.password + config.legacyPath = artifactoryConfig.legacyPath + config.zipPath = artifactoryConfig.zipPath + config.pathSuffix = artifactoryConfig.pathSuffix + val revision = properties.getProperty(ETeamscaleServerProperties.REVISION.name) + val commitString = properties.getProperty(ETeamscaleServerProperties.COMMIT.name) + config.commitInfo = CommitInfo(revision, parse(commitString)) + config.apiKey = artifactoryConfig.apiKey + config.partition = emptyToNull(properties.getProperty(ETeamscaleServerProperties.PARTITION.name)) + setUploadPath(coverageFile, config) + super.upload(coverageFile) + } + + /** Creates properties from the artifactory configs. */ + private fun createArtifactoryProperties() = Properties().apply { + setProperty(ETeamscaleServerProperties.REVISION.name, artifactoryConfig.commitInfo!!.revision) + setProperty(ETeamscaleServerProperties.COMMIT.name, artifactoryConfig.commitInfo!!.commit.toString()) + setProperty(ETeamscaleServerProperties.PARTITION.name, nullToEmpty(artifactoryConfig.partition)) + } + + override fun configureOkHttp(builder: OkHttpClient.Builder) { + super.configureOkHttp(builder) + if (artifactoryConfig.apiKey != null) { + builder.addInterceptor(this.artifactoryApiHeaderInterceptor) + } else { + builder.addInterceptor( + getBasicAuthInterceptor(artifactoryConfig.user!!, artifactoryConfig.password!!) + ) + } + } + + private fun setUploadPath(coverageFile: CoverageFile, artifactoryConfig: ArtifactoryConfig) { + val commit = artifactoryConfig.commitInfo?.commit ?: return + val revision = artifactoryConfig.commitInfo?.revision ?: return + val timeRev = "${commit.timestamp}-${revision}" + val fileName = "${coverageFile.nameWithoutExtension}.zip" + + uploadPath = if (artifactoryConfig.legacyPath) { + "${commit.branchName}/$timeRev/$fileName" + } else { + val suffixSegment = artifactoryConfig.pathSuffix?.let { "$it/" } ?: "" + "uploads/${commit.branchName}/$timeRev/${artifactoryConfig.partition}/$coverageFormat/$suffixSegment$fileName" + } + } + + override fun upload(coverageFile: CoverageFile) { + setUploadPath(coverageFile, this.artifactoryConfig) + super.upload(coverageFile) + } + + @Throws(IOException::class) + override fun uploadCoverageZip(coverageFile: File): Response = + api.uploadCoverageZip(uploadPath!!, coverageFile) + + override fun getZipEntryCoverageFileName(coverageFile: CoverageFile): String { + var path = coverageFile.name + artifactoryConfig.zipPath?.let { path = "$it/$path" } + return path + } + + /** {@inheritDoc} */ + override fun describe() = "Uploading to $uploadUrl" + + private val artifactoryApiHeaderInterceptor: Interceptor + get() = Interceptor { chain -> + val newRequest = chain.request().newBuilder() + .header(ARTIFACTORY_API_HEADER, artifactoryConfig.apiKey!!) + .build() + chain.proceed(newRequest) + } + + companion object { + /** + * Header that can be used as alternative to basic authentication to authenticate requests against artifactory. For + * details check https://www.jfrog.com/confluence/display/JFROG/Artifactory+REST+API + */ + const val ARTIFACTORY_API_HEADER: String = "X-JFrog-Art-Api" + } +} diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/artifactory/IArtifactoryUploadApi.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/artifactory/IArtifactoryUploadApi.kt new file mode 100644 index 000000000..1c25abcf6 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/artifactory/IArtifactoryUploadApi.kt @@ -0,0 +1,29 @@ +package com.teamscale.jacoco.agent.upload.artifactory + +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.create +import okhttp3.ResponseBody +import retrofit2.Call +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.PUT +import retrofit2.http.Path +import java.io.File +import java.io.IOException + +/** [retrofit2.Retrofit] API specification for the [ArtifactoryUploader]. */ +interface IArtifactoryUploadApi { + /** The upload API call. */ + @PUT("{path}") + fun upload(@Path("path") path: String, @Body uploadedFile: RequestBody): Call + + /** + * Convenience method to perform an upload for a coverage zip. + */ + @Throws(IOException::class) + fun uploadCoverageZip(path: String, data: File): Response { + val body = create("application/zip".toMediaTypeOrNull(), data) + return upload(path, body).execute() + } +} diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/delay/DelayedUploader.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/delay/DelayedUploader.kt new file mode 100644 index 000000000..b71e5a564 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/delay/DelayedUploader.kt @@ -0,0 +1,113 @@ +package com.teamscale.jacoco.agent.upload.delay + +import com.teamscale.jacoco.agent.logging.LoggingUtils.getLogger +import com.teamscale.jacoco.agent.upload.IUploader +import com.teamscale.jacoco.agent.util.DaemonThreadFactory +import com.teamscale.report.jacoco.CoverageFile +import java.io.IOException +import java.nio.file.Files +import java.nio.file.Path +import java.util.concurrent.Executor +import java.util.concurrent.Executors +import java.util.function.Function +import kotlin.io.path.isDirectory +import kotlin.io.path.walk + +/** + * Wraps an [IUploader] and in order to delay upload until a all + * information describing a commit is asynchronously made available. + */ +class DelayedUploader internal constructor( + private val wrappedUploaderFactory: Function, + private val cacheDir: Path, + private val executor: Executor +) : IUploader { + private val logger = getLogger(this) + private var wrappedUploader: IUploader? = null + + constructor(wrappedUploaderFactory: Function, cacheDir: Path) : this( + wrappedUploaderFactory, cacheDir, Executors.newSingleThreadExecutor( + DaemonThreadFactory(DelayedUploader::class.java, "Delayed cache upload thread") + ) + ) + + /** + * Visible for testing. Allows tests to control the [Executor] to test the + * asynchronous functionality of this class. + */ + /* package */ + init { + registerShutdownHook() + } + + private fun registerShutdownHook() { + Runtime.getRuntime().addShutdownHook(Thread { + if (wrappedUploader == null) { + logger.error( + ("The application was shut down before a commit could be found. The recorded coverage" + + " is still cached in {} but will not be automatically processed. You configured the" + + " agent to auto-detect the commit to which the recorded coverage should be uploaded to" + + " Teamscale. In order to fix this problem, you need to provide a git.properties file" + + " in all of the profiled Jar/War/Ear/... files. If you're using Gradle or" + + " Maven, you can use a plugin to create a proper git.properties file for you, see" + + " https://docs.spring.io/spring-boot/docs/current/reference/html/howto.html#howto-git-info" + + "\nTo debug problems with git.properties, please enable debug logging for the agent via" + + " the logging-config parameter."), cacheDir.toAbsolutePath() + ) + } + }) + } + + @Synchronized + override fun upload(coverageFile: CoverageFile) { + if (wrappedUploader == null) { + logger.info( + "The commit to upload to has not yet been found. Caching coverage XML in {}", + cacheDir.toAbsolutePath() + ) + } else { + wrappedUploader?.upload(coverageFile) + } + } + + override fun describe() = + wrappedUploader?.describe() ?: "Temporary cache until commit is resolved: ${cacheDir.toAbsolutePath()}" + + /** + * Sets the commit to upload the XMLs to and asynchronously triggers the upload + * of all cached XMLs. This method should only be called once. + */ + @Synchronized + fun setCommitAndTriggerAsynchronousUpload(information: T) { + if (wrappedUploader == null) { + wrappedUploader = wrappedUploaderFactory.apply(information) + logger.info( + "Commit to upload to has been found: {}. Uploading any cached XMLs now to {}", information, + wrappedUploader?.describe() + ) + executor.execute { uploadCachedXmls() } + } else { + logger.error( + "Tried to set upload commit multiple times (old uploader: {}, new commit: {}). This is a programming error. Please report a bug.", + wrappedUploader?.describe(), information + ) + } + } + + private fun uploadCachedXmls() { + try { + if (!cacheDir.isDirectory()) { + // Found data before XML was dumped + return + } + val xmlFilesStream = cacheDir.walk().filter { path -> + val fileName = path.fileName.toString() + fileName.startsWith("jacoco-") && fileName.endsWith(".xml") + } + xmlFilesStream.forEach { path -> wrappedUploader?.upload(CoverageFile(path.toFile())) } + logger.debug("Finished upload of cached XMLs to {}", wrappedUploader?.describe()) + } catch (e: IOException) { + logger.error("Failed to list cached coverage XML files in {}", cacheDir.toAbsolutePath(), e) + } + } +} diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/teamscale/DelayedTeamscaleMultiProjectUploader.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/teamscale/DelayedTeamscaleMultiProjectUploader.kt new file mode 100644 index 000000000..c839ecfe7 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/teamscale/DelayedTeamscaleMultiProjectUploader.kt @@ -0,0 +1,40 @@ +package com.teamscale.jacoco.agent.upload.teamscale + +import com.teamscale.client.TeamscaleServer +import com.teamscale.jacoco.agent.commit_resolution.git_properties.CommitInfo +import com.teamscale.jacoco.agent.options.ProjectAndCommit +import com.teamscale.jacoco.agent.upload.DelayedMultiUploaderBase +import com.teamscale.jacoco.agent.upload.IUploader +import java.io.File +import java.util.function.BiFunction + +/** Wrapper for [TeamscaleUploader] that allows to upload the same coverage file to multiple Teamscale projects. */ +class DelayedTeamscaleMultiProjectUploader( + private val teamscaleServerFactory: BiFunction +) : DelayedMultiUploaderBase(), IUploader { + @JvmField + val teamscaleUploaders = mutableListOf() + + /** + * Adds a teamscale project and commit as a possible new target to upload coverage to. Checks if the project and + * commit are already registered as an upload target and will prevent duplicate uploads. + */ + fun addTeamscaleProjectAndCommit(file: File, projectAndCommit: ProjectAndCommit) { + val teamscaleServer = teamscaleServerFactory.apply( + projectAndCommit.project, + projectAndCommit.commitInfo + ) + + if (teamscaleUploaders.any { it.teamscaleServer.hasSameProjectAndCommit(teamscaleServer) }) { + logger.debug( + "Project and commit in git.properties file {} are already registered as upload target. Coverage will not be uploaded multiple times to the same project {} and commit info {}.", + file, projectAndCommit.project, projectAndCommit.commitInfo + ) + return + } + teamscaleUploaders.add(TeamscaleUploader(teamscaleServer)) + } + + override val wrappedUploaders: MutableCollection + get() = teamscaleUploaders.toMutableList() +} diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/teamscale/ETeamscaleServerProperties.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/teamscale/ETeamscaleServerProperties.kt new file mode 100644 index 000000000..7dfbc8eb0 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/teamscale/ETeamscaleServerProperties.kt @@ -0,0 +1,23 @@ +package com.teamscale.jacoco.agent.upload.teamscale + +/** Describes all the fields of the [com.teamscale.client.TeamscaleServer]. */ +enum class ETeamscaleServerProperties { + /** See [com.teamscale.client.TeamscaleServer.url] */ + URL, + /** See [com.teamscale.client.TeamscaleServer.project] */ + PROJECT, + /** See [com.teamscale.client.TeamscaleServer.userName] */ + USER_NAME, + /** See [com.teamscale.client.TeamscaleServer.userAccessToken] */ + USER_ACCESS_TOKEN, + /** See [com.teamscale.client.TeamscaleServer.partition] */ + PARTITION, + /** See [com.teamscale.client.TeamscaleServer.commit] */ + COMMIT, + /** See [com.teamscale.client.TeamscaleServer.revision] */ + REVISION, + /** See [com.teamscale.client.TeamscaleServer.repository] */ + REPOSITORY, + /** See [com.teamscale.client.TeamscaleServer.message] */ + MESSAGE +} diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/teamscale/TeamscaleUploader.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/teamscale/TeamscaleUploader.kt new file mode 100644 index 000000000..a8aff8869 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/teamscale/TeamscaleUploader.kt @@ -0,0 +1,150 @@ +package com.teamscale.jacoco.agent.upload.teamscale + +import com.teamscale.client.* +import com.teamscale.client.CommitDescriptor.Companion.parse +import com.teamscale.client.FileSystemUtils.normalizeSeparators +import com.teamscale.client.FileSystemUtils.replaceFilePathFilenameWith +import com.teamscale.client.StringUtils.emptyToNull +import com.teamscale.client.StringUtils.nullToEmpty +import com.teamscale.jacoco.agent.benchmark +import com.teamscale.jacoco.agent.logging.LoggingUtils.getLogger +import com.teamscale.jacoco.agent.upload.IUploadRetry +import com.teamscale.jacoco.agent.upload.IUploader +import com.teamscale.jacoco.agent.util.AgentUtils +import com.teamscale.jacoco.agent.util.Benchmark +import com.teamscale.report.jacoco.CoverageFile +import java.io.File +import java.io.IOException +import java.io.OutputStreamWriter +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.util.* + +/** Uploads XML Coverage to a Teamscale instance. */ +class TeamscaleUploader( + @JvmField val teamscaleServer: TeamscaleServer +) : IUploader, IUploadRetry { + private val logger = getLogger(this) + + override fun upload(coverageFile: CoverageFile) { + doUpload(coverageFile, teamscaleServer) + } + + override fun reupload(coverageFile: CoverageFile, properties: Properties) { + val server = TeamscaleServer() + server.project = properties.getProperty(ETeamscaleServerProperties.PROJECT.name) + server.commit = parse(properties.getProperty(ETeamscaleServerProperties.COMMIT.name)) + server.partition = properties.getProperty(ETeamscaleServerProperties.PARTITION.name) + server.revision = emptyToNull(properties.getProperty(ETeamscaleServerProperties.REVISION.name)) + server.repository = emptyToNull(properties.getProperty(ETeamscaleServerProperties.REPOSITORY.name)) + server.userAccessToken = teamscaleServer.userAccessToken + server.userName = teamscaleServer.userName + server.url = teamscaleServer.url + server.message = properties.getProperty(ETeamscaleServerProperties.MESSAGE.name) + doUpload(coverageFile, server) + } + + private fun doUpload(coverageFile: CoverageFile, teamscaleServer: TeamscaleServer) { + benchmark("Uploading report to Teamscale") { + if (tryUploading(coverageFile, teamscaleServer)) { + deleteCoverageFile(coverageFile) + } else { + logger.warn( + ("Failed to upload coverage to Teamscale. " + + "Won't delete local file {} so that the upload can automatically be retried upon profiler restart. " + + "Upload can also be retried manually."), coverageFile + ) + markFileForUploadRetry(coverageFile) + } + } + } + + override fun markFileForUploadRetry(coverageFile: CoverageFile) { + val uploadMetadataFile = File( + replaceFilePathFilenameWith( + normalizeSeparators(coverageFile.toString()), + coverageFile.name + RETRY_UPLOAD_FILE_SUFFIX + ) + ) + val serverProperties = this.createServerProperties() + try { + OutputStreamWriter( + Files.newOutputStream(uploadMetadataFile.toPath()), + StandardCharsets.UTF_8 + ).use { writer -> + serverProperties.store(writer, null) + } + } catch (_: IOException) { + logger.warn( + "Failed to create metadata file for automatic upload retry of {}. Please manually retry the coverage upload to Teamscale.", + coverageFile + ) + uploadMetadataFile.delete() + } + } + + /** + * Creates server properties to be written in a properties file. + */ + private fun createServerProperties() = Properties().apply { + setProperty(ETeamscaleServerProperties.PROJECT.name, teamscaleServer.project) + setProperty(ETeamscaleServerProperties.PARTITION.name, teamscaleServer.partition) + if (teamscaleServer.commit != null) { + setProperty(ETeamscaleServerProperties.COMMIT.name, teamscaleServer.commit.toString()) + } + setProperty(ETeamscaleServerProperties.REVISION.name, nullToEmpty(teamscaleServer.revision)) + setProperty( + ETeamscaleServerProperties.REPOSITORY.name, + nullToEmpty(teamscaleServer.repository) + ) + setProperty(ETeamscaleServerProperties.MESSAGE.name, teamscaleServer.message) + } + + private fun deleteCoverageFile(coverageFile: CoverageFile) { + try { + coverageFile.delete() + } catch (e: IOException) { + logger.warn( + "The upload to Teamscale was successful, but the deletion of the coverage file {} failed. " + + "You can delete it yourself anytime - it is no longer needed.", coverageFile, e + ) + } + } + + /** Performs the upload and returns `true` if successful. */ + private fun tryUploading(coverageFile: CoverageFile, teamscaleServer: TeamscaleServer): Boolean { + logger.debug("Uploading JaCoCo artifact to {}", teamscaleServer) + + try { + // Cannot be executed in the constructor as this causes issues in WildFly server + // (See #100) + TeamscaleServiceGenerator.createService( + teamscaleServer.url!!, + teamscaleServer.userName!!, + teamscaleServer.userAccessToken!!, + AgentUtils.USER_AGENT + ).uploadReport( + teamscaleServer.project!!, + teamscaleServer.commit, + teamscaleServer.revision, + teamscaleServer.repository, teamscaleServer.partition!!, EReportFormat.JACOCO, + teamscaleServer.message!!, coverageFile.createFormRequestBody() + ) + return true + } catch (e: IOException) { + logger.error("Failed to upload coverage to {}", teamscaleServer, e) + return false + } + } + + override fun describe(): String { + return "Uploading to " + teamscaleServer + } + + companion object { + /** + * The properties file suffix for unsuccessful coverage uploads. + */ + const val RETRY_UPLOAD_FILE_SUFFIX: String = "_upload-retry.properties" + } +} diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/util/AgentUtils.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/util/AgentUtils.kt new file mode 100644 index 000000000..6f5eb0d84 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/util/AgentUtils.kt @@ -0,0 +1,57 @@ +package com.teamscale.jacoco.agent.util + +import com.teamscale.client.FileSystemUtils +import com.teamscale.client.TeamscaleServiceGenerator +import com.teamscale.jacoco.agent.PreMain +import com.teamscale.jacoco.agent.configuration.ProcessInformationRetriever +import java.io.IOException +import java.net.URISyntaxException +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.util.* + +/** General utilities for working with the agent. */ +object AgentUtils { + /** Version of this program. */ + val VERSION: String + + /** User-Agent header value for HTTP requests. */ + @JvmField + val USER_AGENT: String + + /** + * Returns the main temporary directory where all agent temp files should be placed. + */ + @JvmStatic + val mainTempDirectory: Path by lazy { + try { + // We add a trailing hyphen here to visually separate the PID from the random number that Java appends + // to the name to make it unique + Files.createTempDirectory( + "teamscale-java-profiler-${FileSystemUtils.toSafeFilename(ProcessInformationRetriever.pID)}-" + ) + } catch (e: IOException) { + throw RuntimeException("Failed to create temporary directory for agent files", e) + } + } + + /** Returns the directory that contains the agent installation. */ + @JvmStatic + val agentDirectory: Path by lazy { + try { + val jarFileUri = PreMain::class.java.getProtectionDomain().codeSource.location.toURI() + // we assume that the dist zip is extracted and the agent jar not moved + val jarDirectory = Paths.get(jarFileUri).parent + jarDirectory.parent ?: jarDirectory // happens when the jar file is stored in the root directory + } catch (e: URISyntaxException) { + throw RuntimeException("Failed to obtain agent directory. This is a bug, please report it.", e) + } + } + + init { + val bundle = ResourceBundle.getBundle("com.teamscale.jacoco.agent.app") + VERSION = bundle.getString("version") + USER_AGENT = TeamscaleServiceGenerator.buildUserAgent("Teamscale Java Profiler", VERSION) + } +} \ No newline at end of file diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/util/Assertions.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/util/Assertions.kt new file mode 100644 index 000000000..ce9b0e44f --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/util/Assertions.kt @@ -0,0 +1,48 @@ +package com.teamscale.jacoco.agent.util + +import org.jetbrains.annotations.Contract + +/** + * Simple methods to implement assertions. + */ +object Assertions { + /** + * Checks if a condition is `true`. + * + * @param condition condition to check + * @param message exception message + * @throws AssertionError if the condition is `false` + */ + @JvmStatic + @Contract(value = "false, _ -> fail", pure = true) + @Throws(AssertionError::class) + fun isTrue(condition: Boolean, message: String?) { + throwAssertionErrorIfTestFails(condition, message) + } + + /** + * Checks if a condition is `false`. + * + * @param condition condition to check + * @param message exception message + * @throws AssertionError if the condition is `true` + */ + @Contract(value = "true, _ -> fail", pure = true) + @Throws(AssertionError::class) + fun isFalse(condition: Boolean, message: String?) { + throwAssertionErrorIfTestFails(!condition, message) + } + + /** + * Throws an [AssertionError] if the test fails. + * + * @param test test which should be true + * @param message exception message + * @throws AssertionError if the test fails + */ + private fun throwAssertionErrorIfTestFails(test: Boolean, message: String?) { + if (!test) { + throw AssertionError(message) + } + } +} \ No newline at end of file diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/util/DaemonThreadFactory.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/util/DaemonThreadFactory.kt new file mode 100644 index 000000000..ac4beb65b --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/util/DaemonThreadFactory.kt @@ -0,0 +1,15 @@ +package com.teamscale.jacoco.agent.util + +import java.util.concurrent.ThreadFactory + +/** + * [java.util.concurrent.ThreadFactory] that only produces deamon threads (threads that don't prevent JVM shutdown) with a fixed name. + */ +class DaemonThreadFactory(owningClass: Class<*>, threadName: String?) : ThreadFactory { + private val threadName = "Teamscale Java Profiler ${owningClass.getSimpleName()} $threadName" + + override fun newThread(runnable: Runnable) = + Thread(runnable, threadName).apply { + setDaemon(true) + } +} \ No newline at end of file diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/util/NullOutputStream.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/util/NullOutputStream.kt new file mode 100644 index 000000000..6f15a8e6b --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/util/NullOutputStream.kt @@ -0,0 +1,20 @@ +package com.teamscale.jacoco.agent.util + +import java.io.IOException +import java.io.OutputStream + +/** NOP output stream implementation. */ +class NullOutputStream : OutputStream() { + override fun write(b: ByteArray, off: Int, len: Int) { + // to /dev/null + } + + override fun write(b: Int) { + // to /dev/null + } + + @Throws(IOException::class) + override fun write(b: ByteArray) { + // to /dev/null + } +} \ No newline at end of file diff --git a/agent/src/test/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitMultiProjectPropertiesLocatorTest.java b/agent/src/test/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitMultiProjectPropertiesLocatorTest.java index ec38ee8b6..b638f7170 100644 --- a/agent/src/test/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitMultiProjectPropertiesLocatorTest.java +++ b/agent/src/test/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitMultiProjectPropertiesLocatorTest.java @@ -27,7 +27,7 @@ void testNoErrorIsThrownWhenGitPropertiesFileDoesNotHaveAProject() { File jarFile = new File(getClass().getResource("emptyTeamscaleProjectGitProperties").getFile()); locator.searchFile(jarFile, false); assertThat(projectAndCommits.size()).isEqualTo(1); - assertThat(projectAndCommits.get(0).getProject()).isEqualTo("my-teamscale-project"); + assertThat(projectAndCommits.get(0).project).isEqualTo("my-teamscale-project"); } @Test @@ -45,8 +45,8 @@ void testNoMultipleUploadsToSameProjectAndRevision() { ); File jarFile = new File(getClass().getResource("multiple-same-target-git-properties-folder").getFile()); locator.searchFile(jarFile, false); - List teamscaleServers = delayedTeamscaleMultiProjectUploader.getTeamscaleUploaders().stream() - .map(TeamscaleUploader::getTeamscaleServer).collect(Collectors.toList()); + List teamscaleServers = delayedTeamscaleMultiProjectUploader.teamscaleUploaders.stream() + .map(t -> t.teamscaleServer).collect(Collectors.toList()); assertThat(teamscaleServers).hasSize(2); assertThat(teamscaleServers).anyMatch(server -> server.project.equals("demo2") && server.commit.equals( new CommitDescriptor("master", "1645713803000"))); diff --git a/teamscale-client/src/main/kotlin/com/teamscale/client/FileSystemUtils.kt b/teamscale-client/src/main/kotlin/com/teamscale/client/FileSystemUtils.kt index 7e6127601..859debcc4 100644 --- a/teamscale-client/src/main/kotlin/com/teamscale/client/FileSystemUtils.kt +++ b/teamscale-client/src/main/kotlin/com/teamscale/client/FileSystemUtils.kt @@ -236,9 +236,9 @@ object FileSystemUtils { @Throws(IOException::class) fun readProperties(propertiesFile: File): Properties { propertiesFile.inputStream().use { stream -> - val props = Properties() - props.load(stream) - return props + return Properties().apply { + load(stream) + } } } diff --git a/teamscale-client/src/main/kotlin/com/teamscale/client/HttpUtils.kt b/teamscale-client/src/main/kotlin/com/teamscale/client/HttpUtils.kt index e69f51e90..d121cec0b 100644 --- a/teamscale-client/src/main/kotlin/com/teamscale/client/HttpUtils.kt +++ b/teamscale-client/src/main/kotlin/com/teamscale/client/HttpUtils.kt @@ -61,8 +61,9 @@ object HttpUtils { @JvmOverloads @JvmStatic fun createRetrofit( - retrofitBuilderAction: Consumer, - okHttpBuilderAction: Consumer, readTimeout: Duration = DEFAULT_READ_TIMEOUT, + retrofitBuilderAction: Retrofit.Builder.() -> Unit, + okHttpBuilderAction: Builder.() -> Unit, + readTimeout: Duration = DEFAULT_READ_TIMEOUT, writeTimeout: Duration = DEFAULT_WRITE_TIMEOUT ): Retrofit { val httpClientBuilder = Builder().apply { @@ -70,10 +71,10 @@ object HttpUtils { setUpSslValidation() setUpProxyServer() } - okHttpBuilderAction.accept(httpClientBuilder) + okHttpBuilderAction(httpClientBuilder) val builder = Retrofit.Builder().client(httpClientBuilder.build()) - retrofitBuilderAction.accept(builder) + retrofitBuilderAction(builder) return builder.build() } diff --git a/teamscale-client/src/main/kotlin/com/teamscale/client/ITeamscaleService.kt b/teamscale-client/src/main/kotlin/com/teamscale/client/ITeamscaleService.kt index 314283eab..c7e52e018 100644 --- a/teamscale-client/src/main/kotlin/com/teamscale/client/ITeamscaleService.kt +++ b/teamscale-client/src/main/kotlin/com/teamscale/client/ITeamscaleService.kt @@ -159,7 +159,7 @@ interface ITeamscaleService { @POST("api/v2024.7.0/profilers/{profilerId}/logs") fun postProfilerLog( @Path("profilerId") profilerId: String, - @Body logEntries: List? + @Body logEntries: List? ): Call } diff --git a/teamscale-client/src/main/kotlin/com/teamscale/client/StringUtils.kt b/teamscale-client/src/main/kotlin/com/teamscale/client/StringUtils.kt index 64800f45d..3e2f1169d 100644 --- a/teamscale-client/src/main/kotlin/com/teamscale/client/StringUtils.kt +++ b/teamscale-client/src/main/kotlin/com/teamscale/client/StringUtils.kt @@ -139,7 +139,7 @@ object StringUtils { * list is returned. */ @JvmStatic - fun splitLinesAsList(content: String?): List = content?.lines() ?: emptyList() + fun splitLinesAsList(content: String?) = content?.lines() ?: emptyList() /** * Test if a string ends with one of the provided suffixes. Returns @@ -147,18 +147,14 @@ object StringUtils { * for short lists of suffixes. */ @JvmStatic - fun endsWithOneOf(string: String, vararg suffixes: String): Boolean { - return suffixes.any { string.endsWith(it) } - } + fun endsWithOneOf(string: String, vararg suffixes: String) = suffixes.any { string.endsWith(it) } /** * Removes double quotes from beginning and end (if present) and returns the new * string. */ @JvmStatic - fun removeDoubleQuotes(string: String): String { - return string.removeSuffix("\"").removePrefix("\"") - } + fun removeDoubleQuotes(string: String) = string.removeSuffix("\"").removePrefix("\"") /** * Converts an empty string to null. If the input string is not empty, it returns the string unmodified. @@ -167,9 +163,7 @@ object StringUtils { * @return `null` if the input string is empty after trimming; the original string otherwise. */ @JvmStatic - fun emptyToNull(string: String): String? { - return if (isEmpty(string)) null else string - } + fun emptyToNull(string: String) = if (isEmpty(string)) null else string /** * Converts a nullable string to a non-null, empty string. @@ -179,7 +173,5 @@ object StringUtils { * @return a non-null string; either the original string or an empty string if the input was null */ @JvmStatic - fun nullToEmpty(stringOrNull: String?): String { - return stringOrNull ?: EMPTY_STRING - } + fun nullToEmpty(stringOrNull: String?) = stringOrNull ?: EMPTY_STRING } diff --git a/teamscale-client/src/main/kotlin/com/teamscale/client/TeamscaleClient.kt b/teamscale-client/src/main/kotlin/com/teamscale/client/TeamscaleClient.kt index 4691c9bf3..c48288d60 100644 --- a/teamscale-client/src/main/kotlin/com/teamscale/client/TeamscaleClient.kt +++ b/teamscale-client/src/main/kotlin/com/teamscale/client/TeamscaleClient.kt @@ -34,7 +34,7 @@ open class TeamscaleClient { val url = baseUrl?.toHttpUrlOrNull() ?: throw IllegalArgumentException("Invalid URL: $baseUrl") this.projectId = projectId service = TeamscaleServiceGenerator.createService( - ITeamscaleService::class.java, url, user, accessToken, userAgent, readTimeout, writeTimeout + url, user, accessToken, userAgent, readTimeout, writeTimeout ) } @@ -53,7 +53,7 @@ open class TeamscaleClient { val url = baseUrl?.toHttpUrlOrNull() ?: throw IllegalArgumentException("Invalid URL: $baseUrl") this.projectId = projectId service = TeamscaleServiceGenerator.createServiceWithRequestLogging( - ITeamscaleService::class.java, url, user, accessToken, logfile, readTimeout, writeTimeout, userAgent + url, user, accessToken, logfile, readTimeout, writeTimeout, userAgent ) } diff --git a/teamscale-client/src/main/kotlin/com/teamscale/client/TeamscaleServiceGenerator.kt b/teamscale-client/src/main/kotlin/com/teamscale/client/TeamscaleServiceGenerator.kt index 54d925131..1597381de 100644 --- a/teamscale-client/src/main/kotlin/com/teamscale/client/TeamscaleServiceGenerator.kt +++ b/teamscale-client/src/main/kotlin/com/teamscale/client/TeamscaleServiceGenerator.kt @@ -20,10 +20,7 @@ object TeamscaleServiceGenerator { * Generates a [Retrofit] instance for the given service, which uses basic auth to authenticate against the * server and which sets the accept header to json. */ - @JvmStatic - @JvmOverloads - fun createService( - serviceClass: Class, + inline fun createService( baseUrl: HttpUrl, username: String, accessToken: String, @@ -31,16 +28,15 @@ object TeamscaleServiceGenerator { readTimeout: Duration = HttpUtils.DEFAULT_READ_TIMEOUT, writeTimeout: Duration = HttpUtils.DEFAULT_WRITE_TIMEOUT, vararg interceptors: Interceptor - ) = createServiceWithRequestLogging( - serviceClass, baseUrl, username, accessToken, null, readTimeout, writeTimeout, userAgent, *interceptors + ) = createServiceWithRequestLogging( + baseUrl, username, accessToken, null, readTimeout, writeTimeout, userAgent, *interceptors ) /** * Generates a [Retrofit] instance for the given service, which uses basic auth to authenticate against the * server and which sets the accept-header to json. Logs requests and responses to the given logfile. */ - fun createServiceWithRequestLogging( - serviceClass: Class, + inline fun createServiceWithRequestLogging( baseUrl: HttpUrl, username: String, accessToken: String, @@ -50,33 +46,22 @@ object TeamscaleServiceGenerator { userAgent: String, vararg interceptors: Interceptor ): S = HttpUtils.createRetrofit( - { retrofitBuilder -> - retrofitBuilder.baseUrl(baseUrl) - .addConverterFactory(JacksonConverterFactory.create(JsonUtils.OBJECT_MAPPER)) - }, - { okHttpBuilder -> - okHttpBuilder.addInterceptors(*interceptors) - .addInterceptor(HttpUtils.getBasicAuthInterceptor(username, accessToken)) - .addInterceptor(AcceptJsonInterceptor()) - .addNetworkInterceptor(CustomUserAgentInterceptor(userAgent)) - logfile?.let { okHttpBuilder.addInterceptor(FileLoggingInterceptor(it)) } + { + baseUrl(baseUrl).addConverterFactory(JacksonConverterFactory.create(JsonUtils.OBJECT_MAPPER)) }, - readTimeout, writeTimeout - ).create(serviceClass) - - private fun OkHttpClient.Builder.addInterceptors( - vararg interceptors: Interceptor - ): OkHttpClient.Builder { - interceptors.forEach { interceptor -> - addInterceptor(interceptor) - } - return this - } + { + interceptors.forEach { addInterceptor(it) } + addInterceptor(HttpUtils.getBasicAuthInterceptor(username, accessToken)) + addInterceptor(AcceptJsonInterceptor()) + addNetworkInterceptor(CustomUserAgentInterceptor(userAgent)) + logfile?.let { addInterceptor(FileLoggingInterceptor(it)) } + }, readTimeout, writeTimeout + ).create(S::class.java) /** * Sets an `Accept: application/json` header on all requests. */ - private class AcceptJsonInterceptor : Interceptor { + class AcceptJsonInterceptor : Interceptor { @Throws(IOException::class) override fun intercept(chain: Interceptor.Chain): Response { val newRequest = chain.request().newBuilder().header("Accept", "application/json").build() diff --git a/teamscale-client/src/test/kotlin/com/teamscale/client/TeamscaleServiceGeneratorProxyServerTest.kt b/teamscale-client/src/test/kotlin/com/teamscale/client/TeamscaleServiceGeneratorProxyServerTest.kt index b88e50adb..77b7fdf9c 100644 --- a/teamscale-client/src/test/kotlin/com/teamscale/client/TeamscaleServiceGeneratorProxyServerTest.kt +++ b/teamscale-client/src/test/kotlin/com/teamscale/client/TeamscaleServiceGeneratorProxyServerTest.kt @@ -59,8 +59,7 @@ internal class TeamscaleServiceGeneratorProxyServerTest { @Throws(InterruptedException::class, IOException::class) private fun assertProxyAuthenticationIsUsed(base64EncodedBasicAuth: String) { - val service = createService( - ITeamscaleService::class.java, + val service = createService( "http://localhost:1337".toHttpUrl(), "someUser", "someAccesstoken", userAgent = buildUserAgent("Test Tool", "1.0.0")