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..91fba497c 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 @@ -60,6 +61,7 @@ dependencies { testImplementation(project(":tia-client")) testImplementation(libs.retrofit.converter.jackson) testImplementation(libs.okhttp.mockwebserver) + testImplementation(libs.mockito.kotlin) } 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/AgentOptionParseException.java b/agent/src/main/java/com/teamscale/jacoco/agent/options/AgentOptionParseException.java deleted file mode 100644 index f8b323d6b..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/options/AgentOptionParseException.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.teamscale.jacoco.agent.options; - -/** - * Thrown if option parsing fails. - */ -public class AgentOptionParseException extends Exception { - - /** - * Serialization ID. - */ - private static final long serialVersionUID = 1L; - - public AgentOptionParseException(String message) { - super(message); - } - - public AgentOptionParseException(Exception e) { - super(e.getMessage(), e); - } - - public AgentOptionParseException(String message, Throwable cause) { - super(message, cause); - } -} 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 deleted file mode 100644 index 4a19f3e3f..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/options/AgentOptions.java +++ /dev/null @@ -1,765 +0,0 @@ -/*-------------------------------------------------------------------------+ -| | -| Copyright (c) 2009-2018 CQSE GmbH | -| | -+-------------------------------------------------------------------------*/ -package com.teamscale.jacoco.agent.options; - -import com.teamscale.client.EReportFormat; -import com.teamscale.client.FileSystemUtils; -import com.teamscale.client.ProxySystemProperties; -import com.teamscale.client.StringUtils; -import com.teamscale.client.TeamscaleClient; -import com.teamscale.client.TeamscaleServer; -import com.teamscale.jacoco.agent.commandline.Validator; -import com.teamscale.jacoco.agent.commit_resolution.git_properties.CommitInfo; -import com.teamscale.jacoco.agent.commit_resolution.git_properties.GitMultiProjectPropertiesLocator; -import com.teamscale.jacoco.agent.commit_resolution.git_properties.GitPropertiesLocatingTransformer; -import com.teamscale.jacoco.agent.commit_resolution.git_properties.GitPropertiesLocatorUtils; -import com.teamscale.jacoco.agent.commit_resolution.git_properties.GitSingleProjectPropertiesLocator; -import com.teamscale.jacoco.agent.commit_resolution.sapnwdi.NwdiMarkerClassLocatingTransformer; -import com.teamscale.jacoco.agent.configuration.ConfigurationViaTeamscale; -import com.teamscale.jacoco.agent.options.sapnwdi.DelayedSapNwdiMultiUploader; -import com.teamscale.jacoco.agent.options.sapnwdi.SapNwdiApplication; -import com.teamscale.jacoco.agent.upload.IUploader; -import com.teamscale.jacoco.agent.upload.LocalDiskUploader; -import com.teamscale.jacoco.agent.upload.UploaderException; -import com.teamscale.jacoco.agent.upload.artifactory.ArtifactoryConfig; -import com.teamscale.jacoco.agent.upload.artifactory.ArtifactoryUploader; -import com.teamscale.jacoco.agent.upload.azure.AzureFileStorageConfig; -import com.teamscale.jacoco.agent.upload.azure.AzureFileStorageUploader; -import com.teamscale.jacoco.agent.upload.delay.DelayedUploader; -import com.teamscale.jacoco.agent.upload.teamscale.DelayedTeamscaleMultiProjectUploader; -import com.teamscale.jacoco.agent.upload.teamscale.TeamscaleConfig; -import com.teamscale.jacoco.agent.upload.teamscale.TeamscaleUploader; -import com.teamscale.jacoco.agent.util.AgentUtils; -import com.teamscale.jacoco.agent.util.Assertions; -import com.teamscale.report.EDuplicateClassFileBehavior; -import com.teamscale.report.util.ClasspathWildcardIncludeFilter; -import com.teamscale.report.util.ILogger; -import kotlin.Pair; -import org.jacoco.core.runtime.WildcardMatcher; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -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.nio.file.Paths; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; -import java.util.ArrayList; -import java.util.List; -import java.util.Locale; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import java.util.stream.Stream; - -/** - * Parses agent command line options. - */ -public class AgentOptions { - - /** - * Can be used to format {@link LocalDate} to the format "yyyy-MM-dd-HH-mm-ss.SSS" - */ - /* package */ static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter - .ofPattern("yyyy-MM-dd-HH-mm-ss.SSS", Locale.ENGLISH); - - /** - * The default excludes applied to JaCoCo. These are packages that should never be profiled. Excluding them makes - * debugging traces easier and reduces trace size and warnings about unmatched classes in Teamscale. - */ - public static final String DEFAULT_EXCLUDES = "shadow.*:com.sun.*:sun.*:org.eclipse.*:org.junit.*:junit.*:org.apache.*:org.slf4j.*:javax.*:org.gradle.*:java.*:org.jboss.*:org.wildfly.*:org.springframework.*:com.fasterxml.*:jakarta.*:org.aspectj.*:org.h2.*:org.hibernate.*:org.assertj.*:org.mockito.*:org.thymeleaf.*"; - - private final ILogger logger; - - /** See {@link AgentOptions#GIT_PROPERTIES_JAR_OPTION} */ - /* package */ File gitPropertiesJar; - - /** - * Related to {@link AgentOptions#GIT_PROPERTIES_COMMIT_DATE_FORMAT_OPTION} - */ - public DateTimeFormatter gitPropertiesCommitTimeFormat = null; - - /** Option name that allows to specify a jar file that contains the git commit hash in a git.properties file. */ - public static final String GIT_PROPERTIES_JAR_OPTION = "git-properties-jar"; - - /** - * Specifies the date format in which the commit timestamp in the git.properties file is formatted. - */ - public static final String GIT_PROPERTIES_COMMIT_DATE_FORMAT_OPTION = "git-properties-commit-date-format"; - - /** - * The original options passed to the agent. - */ - /* package */ String originalOptionsString; - - /** Whether debug logging is active or not. */ - /* package */ boolean debugLogging = false; - - /** Explicitly defined log file. */ - /* package */ Path debugLogDirectory = null; - - /** - * The directories and/or zips that contain all class files being profiled. Never null. If this is empty, classes - * should be dumped to a temporary directory which should be used as the class-dir. - */ - /* package */ List classDirectoriesOrZips = new ArrayList<>(); - - /** - * The logging configuration file. - */ - /* package */ Path loggingConfig = null; - - /** - * The directory to write the XML traces to. - */ - private Path outputDirectory; - - /** Contains the options related to teamscale-specific proxy settings for http. */ - /* package */ TeamscaleProxyOptions teamscaleProxyOptionsForHttp; - - /** Contains the options related to teamscale-specific proxy settings for https. */ - /* package */ TeamscaleProxyOptions teamscaleProxyOptionsForHttps; - - /** - * Additional metadata files to upload together with the coverage XML. - */ - /* package */ List additionalMetaDataFiles = new ArrayList<>(); - - /** Whether the agent should be run in testwise coverage mode or normal mode. */ - /* package */ EMode mode = EMode.NORMAL; - - /** - * The interval in minutes for dumping XML data. - */ - /* package */ int dumpIntervalInMinutes = 480; - - /** Whether to dump coverage when the JVM shuts down. */ - /* package */ boolean shouldDumpOnExit = true; - - /** - * Whether to search directories and jar files recursively for git.properties files - */ - /* package */ boolean searchGitPropertiesRecursively = true; - - /** - * Whether to validate SSL certificates, defaults to true. - */ - /* package */ boolean validateSsl = true; - - /** - * Whether to ignore duplicate, non-identical class files. - */ - /* package */ EDuplicateClassFileBehavior duplicateClassFileBehavior = EDuplicateClassFileBehavior.WARN; - - /** - * Include patterns for fully qualified class names to pass on to JaCoCo. See {@link WildcardMatcher} for the - * pattern syntax. Individual patterns must be separated by ":". - */ - /* package */ String jacocoIncludes = null; - - /** - * Exclude patterns for fully qualified class names to pass on to JaCoCo. See {@link WildcardMatcher} for the - * pattern syntax. Individual patterns must be separated by ":". - */ - /* package */ String jacocoExcludes = DEFAULT_EXCLUDES; - - /** - * Additional user-provided options to pass to JaCoCo. - */ - /* package */ List> additionalJacocoOptions = new ArrayList<>(); - - /** - * The teamscale server to which coverage should be uploaded. - */ - /* package */ TeamscaleServer teamscaleServer = new TeamscaleServer(); - - /** - * How testwise coverage should be handled in test-wise mode. - */ - /*package*/ ETestwiseCoverageMode testwiseCoverageMode = ETestwiseCoverageMode.EXEC_FILE; - - /** - * The port on which the HTTP server should be listening. - */ - /* package */ Integer httpServerPort = null; - - /** - * Whether classes without coverage should be skipped from the XML report. - */ - /* package */ boolean ignoreUncoveredClasses = false; - - /** - * The configuration necessary to upload files to an azure file storage - */ - /* package */ ArtifactoryConfig artifactoryConfig = new ArtifactoryConfig(); - - /** - * The configuration necessary to upload files to an azure file storage - */ - /* package */ AzureFileStorageConfig azureFileStorageConfig = new AzureFileStorageConfig(); - - /** - * The configuration necessary when used in an SAP NetWeaver Java environment. - */ - /* package */ List sapNetWeaverJavaApplications = new ArrayList<>(); - - /** - * Whether to obfuscate security related configuration options when dumping them into the log or onto the console or - * not. - */ - /* package */ boolean obfuscateSecurityRelatedOutputs = true; - - /** - * Helper class that holds the process information, Teamscale client and profiler configuration and allows to - * continuously update the profiler's info in Teamscale in the background via - * {@link ConfigurationViaTeamscale#startHeartbeatThreadAndRegisterShutdownHook()}. - */ - public ConfigurationViaTeamscale configurationViaTeamscale; - - public AgentOptions(ILogger logger) { - this.logger = logger; - setParentOutputDirectory(AgentUtils.getMainTempDirectory().resolve("coverage")); - teamscaleProxyOptionsForHttp = new TeamscaleProxyOptions( - ProxySystemProperties.Protocol.HTTP, logger); - teamscaleProxyOptionsForHttps = new TeamscaleProxyOptions( - ProxySystemProperties.Protocol.HTTPS, logger); - } - - /** @see #debugLogging */ - public boolean isDebugLogging() { - return debugLogging; - } - - /** @see #debugLogDirectory */ - public Path getDebugLogDirectory() { - return debugLogDirectory; - } - - /** - * @see #originalOptionsString - */ - public String getOriginalOptionsString() { - return originalOptionsString; - } - - /** - * Remove parts of the API key for security reasons from the options string. String is used for logging purposes. - *

- * Given, for example, "config-file=jacocoagent.properties,teamscale-access-token=unlYgehaYYYhbPAegNWV3WgjOzxkmNHn" - * we produce a string with obfuscation: - * "config-file=jacocoagent.properties,teamscale-access-token=************mNHn" - */ - public String getObfuscatedOptionsString() { - if (getOriginalOptionsString() == null) { - return ""; - } - - Pattern pattern = Pattern.compile("(.*-access-token=)([^,]+)(.*)"); - Matcher match = pattern.matcher(getOriginalOptionsString()); - if (match.find()) { - String apiKey = match.group(2); - String obfuscatedApiKey = String.format("************%s", apiKey.substring(Math.max(0, - apiKey.length() - 4))); - return String.format("%s%s%s", match.group(1), obfuscatedApiKey, match.group(3)); - } - - return getOriginalOptionsString(); - } - - /** - * Validates the options and returns a validator with all validation errors. - */ - /* package */ Validator getValidator() { - Validator validator = new Validator(); - - validateFilePaths(validator); - - if (loggingConfig != null) { - validateLoggingConfig(validator); - } - - validateTeamscaleUploadConfig(validator); - - validateUploadConfig(validator); - - validateSapNetWeaverConfig(validator); - - if (useTestwiseCoverageMode()) { - validateTestwiseCoverageConfig(validator); - } - - return validator; - } - - private void validateFilePaths(Validator validator) { - for (File path : classDirectoriesOrZips) { - validator.isTrue(path.exists(), "Path '" + path + "' does not exist"); - validator.isTrue(path.canRead(), "Path '" + path + "' is not readable"); - } - } - - private void validateLoggingConfig(Validator validator) { - validator.ensure(() -> { - Assertions.isTrue(Files.exists(loggingConfig), - "The path provided for the logging configuration does not exist: " + loggingConfig); - Assertions.isTrue(Files.isRegularFile(loggingConfig), - "The path provided for the logging configuration is not a file: " + loggingConfig); - Assertions.isTrue(Files.isReadable(loggingConfig), - "The file provided for the logging configuration is not readable: " + loggingConfig); - Assertions.isTrue("xml".equalsIgnoreCase(FileSystemUtils.getFileExtension(loggingConfig.toFile())), - "The logging configuration file must have the file extension .xml and be a valid XML file"); - }); - } - - private void validateTeamscaleUploadConfig(Validator validator) { - validator.isTrue( - teamscaleServer.hasAllFieldsNull() || teamscaleServer.canConnectToTeamscale() || teamscaleServer.isConfiguredForSingleProjectTeamscaleUpload() || teamscaleServer.isConfiguredForMultiProjectUpload(), - "You did provide some options prefixed with 'teamscale-', but not all required ones!"); - - validator.isFalse(teamscaleServer.isConfiguredForMultiProjectUpload() && (teamscaleServer.revision != null - || teamscaleServer.commit != null), - "You tried to provide a commit to upload to directly. This is not possible, since you" + - " did not provide the 'teamscale-project' to upload to. Please either specify the 'teamscale-project'" + - " property, or provide the respective projects and commits via all the profiled Jar/War/Ear/...s' " + - " git.properties files."); - - validator.isTrue(teamscaleServer.revision == null || teamscaleServer.commit == null, - "'" + TeamscaleConfig.TEAMSCALE_REVISION_OPTION + "' and '" + TeamscaleConfig.TEAMSCALE_REVISION_MANIFEST_JAR_OPTION + "' are incompatible with '" + TeamscaleConfig.TEAMSCALE_COMMIT_OPTION + "' and '" + - TeamscaleConfig.TEAMSCALE_COMMIT_MANIFEST_JAR_OPTION + "'."); - - validator.isTrue(teamscaleServer.project == null || teamscaleServer.partition != null, - "You configured a 'teamscale-project' but no 'teamscale-partition' to upload to."); - } - - private void validateUploadConfig(Validator validator) { - validator.isTrue((artifactoryConfig.hasAllRequiredFieldsSet() || artifactoryConfig - .hasAllRequiredFieldsNull()), - String.format("If you want to upload data to Artifactory you need to provide " + - "'%s', '%s' and an authentication method (either '%s' and '%s' or '%s') ", - ArtifactoryConfig.ARTIFACTORY_URL_OPTION, - ArtifactoryConfig.ARTIFACTORY_PARTITION, - ArtifactoryConfig.ARTIFACTORY_USER_OPTION, ArtifactoryConfig.ARTIFACTORY_PASSWORD_OPTION, - ArtifactoryConfig.ARTIFACTORY_API_KEY_OPTION)); - - validator.isTrue((azureFileStorageConfig.hasAllRequiredFieldsSet() || azureFileStorageConfig - .hasAllRequiredFieldsNull()), - "If you want to upload data to an Azure file storage you need to provide both " + - "'azure-url' and 'azure-key' "); - - long configuredStores = Stream - .of(artifactoryConfig.hasAllRequiredFieldsSet(), azureFileStorageConfig.hasAllRequiredFieldsSet(), - teamscaleServer.isConfiguredForSingleProjectTeamscaleUpload(), - teamscaleServer.isConfiguredForMultiProjectUpload()).filter(x -> x) - .count(); - - validator.isTrue(configuredStores <= 1, "You cannot configure multiple upload stores, " + - "such as a Teamscale instance, upload URL, Azure file storage or artifactory"); - } - - private void validateSapNetWeaverConfig(Validator validator) { - if (sapNetWeaverJavaApplications.isEmpty()) { - return; - } - - validator.isTrue(teamscaleServer.project == null, - "You provided an SAP NWDI applications config and a teamscale-project. This is not allowed. " + - "The project must be specified via sap-nwdi-applications!"); - - validator.isTrue(teamscaleServer.project != null || teamscaleServer.isConfiguredForMultiProjectUpload(), - "You provided an SAP NWDI applications config, but the 'teamscale-' upload options are incomplete."); - - } - - private void validateTestwiseCoverageConfig(Validator validator) { - validator.isTrue(httpServerPort != null, - "You use 'mode=testwise' but did not specify the required option 'http-server-port'!"); - - validator.isTrue(testwiseCoverageMode != ETestwiseCoverageMode.TEAMSCALE_UPLOAD - || teamscaleServer.isConfiguredForSingleProjectTeamscaleUpload(), - "You use 'tia-mode=teamscale-upload' but did not set all required 'teamscale-' fields to facilitate" + - " a connection to Teamscale!"); - - validator.isTrue( - testwiseCoverageMode != ETestwiseCoverageMode.TEAMSCALE_UPLOAD || teamscaleServer.hasCommitOrRevision(), - "You use 'tia-mode=teamscale-upload' but did not provide a revision or commit via the agent's '" + TeamscaleConfig.TEAMSCALE_REVISION_OPTION + "', '" + - TeamscaleConfig.TEAMSCALE_REVISION_MANIFEST_JAR_OPTION + "', '" + TeamscaleConfig.TEAMSCALE_COMMIT_OPTION + - "', '" + TeamscaleConfig.TEAMSCALE_COMMIT_MANIFEST_JAR_OPTION + "' or '" + - AgentOptions.GIT_PROPERTIES_JAR_OPTION + "' option." + - " Auto-detecting the git.properties is currently not supported in this mode."); - } - - /** - * Creates a {@link TeamscaleClient} based on the agent options. Returns null if the user did not fully configure a - * Teamscale connection. - */ - public @Nullable TeamscaleClient createTeamscaleClient(boolean requireSingleProjectUploadConfig) { - if (teamscaleServer.isConfiguredForSingleProjectTeamscaleUpload() || - !requireSingleProjectUploadConfig && teamscaleServer.isConfiguredForServerConnection()) { - return new TeamscaleClient(teamscaleServer.url.toString(), teamscaleServer.userName, - teamscaleServer.userAccessToken, teamscaleServer.project, - AgentUtils.USER_AGENT); - } - return null; - } - - /** All available upload methods. */ - /*package*/ enum EUploadMethod { - /** Saving coverage files on disk. */ - LOCAL_DISK, - /** Sending coverage to a single Teamscale project. */ - TEAMSCALE_SINGLE_PROJECT, - /** Sending coverage to multiple Teamscale projects. */ - TEAMSCALE_MULTI_PROJECT, - /** Sending coverage to multiple Teamscale projects based on SAP NWDI application definitions. */ - SAP_NWDI_TEAMSCALE, - /** Sending coverage to an Artifactory. */ - ARTIFACTORY, - /** Sending coverage to Azure file storage. */ - AZURE_FILE_STORAGE, - } - - /** Determines the upload method that should be used based on the set options. */ - /*package*/ EUploadMethod determineUploadMethod() { - if (artifactoryConfig.hasAllRequiredFieldsSet()) { - return EUploadMethod.ARTIFACTORY; - } - if (azureFileStorageConfig.hasAllRequiredFieldsSet()) { - return EUploadMethod.AZURE_FILE_STORAGE; - } - if (!sapNetWeaverJavaApplications.isEmpty()) { - return EUploadMethod.SAP_NWDI_TEAMSCALE; - } - if (teamscaleServer.isConfiguredForMultiProjectUpload()) { - return EUploadMethod.TEAMSCALE_MULTI_PROJECT; - } - if (teamscaleServer.isConfiguredForSingleProjectTeamscaleUpload()) { - return EUploadMethod.TEAMSCALE_SINGLE_PROJECT; - } - return EUploadMethod.LOCAL_DISK; - } - - /** - * Creates an uploader for the coverage XMLs. - */ - public IUploader createUploader(Instrumentation instrumentation) throws UploaderException { - EUploadMethod uploadMethod = determineUploadMethod(); - switch (uploadMethod) { - case TEAMSCALE_MULTI_PROJECT: - return createTeamscaleMultiProjectUploader(instrumentation); - case TEAMSCALE_SINGLE_PROJECT: - return createTeamscaleSingleProjectUploader(instrumentation); - case ARTIFACTORY: - return createArtifactoryUploader(instrumentation); - case AZURE_FILE_STORAGE: - return new AzureFileStorageUploader(azureFileStorageConfig, - additionalMetaDataFiles); - case SAP_NWDI_TEAMSCALE: { - logger.info("NWDI configuration detected. The Agent will try and" + - " auto-detect commit information by searching all profiled Jar/War/Ear/... files."); - return createNwdiTeamscaleUploader(instrumentation); - } - case LOCAL_DISK: - return new LocalDiskUploader(); - default: - throw new RuntimeException("Unhandled upload method " + uploadMethod + "." - + " This is a bug, please report this to CQSE."); - } - } - - @NotNull - private IUploader createArtifactoryUploader(Instrumentation instrumentation) throws UploaderException { - if (gitPropertiesJar != null) { - logger.info("You did not provide a commit to upload to directly, so the Agent will try to" + - "auto-detect it by searching the provided " + GIT_PROPERTIES_JAR_OPTION + " at " + - gitPropertiesJar.getAbsolutePath() + " for a git.properties file."); - artifactoryConfig.commitInfo = ArtifactoryConfig.parseGitProperties(gitPropertiesJar, - this.searchGitPropertiesRecursively, this.gitPropertiesCommitTimeFormat); - } - if (!artifactoryConfig.hasCommitInfo()) { - logger.info("You did not provide a commit to upload to directly, so the Agent will try and" + - " auto-detect it by searching all profiled Jar/War/Ear/... files for a git.properties file."); - return createDelayedArtifactoryUploader(instrumentation); - } - return new ArtifactoryUploader(artifactoryConfig, - additionalMetaDataFiles, getReportFormat()); - } - - @NotNull - private IUploader createTeamscaleSingleProjectUploader(Instrumentation instrumentation) { - if (teamscaleServer.hasCommitOrRevision()) { - return new TeamscaleUploader(teamscaleServer); - } - - DelayedUploader uploader = createDelayedSingleProjectTeamscaleUploader(); - - if (gitPropertiesJar != null) { - logger.info("You did not provide a commit to upload to directly, so the Agent will try to" + - "auto-detect it by searching the provided " + GIT_PROPERTIES_JAR_OPTION + " at " + - gitPropertiesJar.getAbsolutePath() + " for a git.properties file."); - startGitPropertiesSearchInJarFile(uploader, gitPropertiesJar); - return uploader; - } - - logger.info("You did not provide a commit to upload to directly, so the Agent will try and" + - " auto-detect it by searching all profiled Jar/War/Ear/... files for a git.properties file."); - registerSingleGitPropertiesLocator(uploader, instrumentation); - return uploader; - } - - @NotNull - private DelayedTeamscaleMultiProjectUploader createTeamscaleMultiProjectUploader( - Instrumentation instrumentation) { - DelayedTeamscaleMultiProjectUploader uploader = new DelayedTeamscaleMultiProjectUploader( - (project, commitInfo) -> { - if (commitInfo.preferCommitDescriptorOverRevision || StringUtils.isEmpty(commitInfo.revision)) { - return teamscaleServer.withProjectAndCommit(project, commitInfo.commit); - } - return teamscaleServer.withProjectAndRevision(project, commitInfo.revision); - }); - - if (gitPropertiesJar != null) { - logger.info( - "You did not provide a Teamscale project to upload to directly, so the Agent will try and" + - " auto-detect it by searching the provided " + GIT_PROPERTIES_JAR_OPTION + " at " + - gitPropertiesJar.getAbsolutePath() + " for a git.properties file."); - - startMultiGitPropertiesFileSearchInJarFile(uploader, gitPropertiesJar); - return uploader; - } - - logger.info("You did not provide a Teamscale project to upload to directly, so the Agent will try and" + - " auto-detect it by searching all profiled Jar/War/Ear/... files for git.properties files" + - " with the 'teamscale.project' field set."); - registerMultiGitPropertiesLocator(uploader, instrumentation); - return uploader; - } - - private void startGitPropertiesSearchInJarFile(DelayedUploader uploader, - File gitPropertiesJar) { - GitSingleProjectPropertiesLocator locator = new GitSingleProjectPropertiesLocator<>(uploader, - GitPropertiesLocatorUtils::getProjectRevisionsFromGitProperties, this.searchGitPropertiesRecursively, - this.gitPropertiesCommitTimeFormat); - locator.searchFileForGitPropertiesAsync(gitPropertiesJar, true); - } - - private void registerSingleGitPropertiesLocator(DelayedUploader uploader, - Instrumentation instrumentation) { - GitSingleProjectPropertiesLocator locator = new GitSingleProjectPropertiesLocator<>(uploader, - GitPropertiesLocatorUtils::getProjectRevisionsFromGitProperties, this.searchGitPropertiesRecursively, - this.gitPropertiesCommitTimeFormat); - instrumentation.addTransformer(new GitPropertiesLocatingTransformer(locator, getLocationIncludeFilter())); - } - - private DelayedUploader createDelayedSingleProjectTeamscaleUploader() { - return new DelayedUploader<>( - projectAndCommit -> { - if (!StringUtils.isEmpty(projectAndCommit.getProject()) && !teamscaleServer.project - .equals(projectAndCommit.getProject())) { - 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."); - } - if (projectAndCommit.getCommitInfo().preferCommitDescriptorOverRevision || - StringUtils.isEmpty(projectAndCommit.getCommitInfo().revision)) { - teamscaleServer.commit = projectAndCommit.getCommitInfo().commit; - } else { - teamscaleServer.revision = projectAndCommit.getCommitInfo().revision; - } - return new TeamscaleUploader(teamscaleServer); - }, outputDirectory); - } - - private void startMultiGitPropertiesFileSearchInJarFile(DelayedTeamscaleMultiProjectUploader uploader, - File gitPropertiesJar) { - GitMultiProjectPropertiesLocator locator = new GitMultiProjectPropertiesLocator(uploader, - this.searchGitPropertiesRecursively, this.gitPropertiesCommitTimeFormat); - locator.searchFileForGitPropertiesAsync(gitPropertiesJar, true); - } - - private void registerMultiGitPropertiesLocator(DelayedTeamscaleMultiProjectUploader uploader, - Instrumentation instrumentation) { - GitMultiProjectPropertiesLocator locator = new GitMultiProjectPropertiesLocator(uploader, - this.searchGitPropertiesRecursively, this.gitPropertiesCommitTimeFormat); - instrumentation.addTransformer(new GitPropertiesLocatingTransformer(locator, getLocationIncludeFilter())); - } - - private IUploader createDelayedArtifactoryUploader(Instrumentation instrumentation) { - DelayedUploader uploader = new DelayedUploader<>( - commitInfo -> { - artifactoryConfig.commitInfo = commitInfo; - return new ArtifactoryUploader(artifactoryConfig, additionalMetaDataFiles, - getReportFormat()); - }, outputDirectory); - GitSingleProjectPropertiesLocator locator = new GitSingleProjectPropertiesLocator<>( - uploader, - GitPropertiesLocatorUtils::getCommitInfoFromGitProperties, - this.searchGitPropertiesRecursively, this.gitPropertiesCommitTimeFormat); - instrumentation.addTransformer(new GitPropertiesLocatingTransformer(locator, getLocationIncludeFilter())); - return uploader; - } - - private IUploader createNwdiTeamscaleUploader(Instrumentation instrumentation) { - DelayedSapNwdiMultiUploader uploader = new DelayedSapNwdiMultiUploader( - (commit, application) -> new TeamscaleUploader( - teamscaleServer.withProjectAndCommit(application.getTeamscaleProject(), commit))); - instrumentation.addTransformer(new NwdiMarkerClassLocatingTransformer(uploader, getLocationIncludeFilter(), - sapNetWeaverJavaApplications)); - return uploader; - } - - private EReportFormat getReportFormat() { - if (useTestwiseCoverageMode()) { - return EReportFormat.TESTWISE_COVERAGE; - } - return EReportFormat.JACOCO; - } - - /** - * @see #classDirectoriesOrZips - */ - public List getClassDirectoriesOrZips() { - return classDirectoriesOrZips; - } - - /** @see #teamscaleServer */ - public TeamscaleServer getTeamscaleServerOptions() { - return teamscaleServer; - } - - /** - * Get the directory to which the coverage files are written to - */ - public Path getOutputDirectory() { - return outputDirectory; - } - - /** - * Creates a new file with the given prefix, extension and current timestamp and ensures that the parent folder - * actually exists. - */ - public File createNewFileInOutputDirectory(String prefix, String extension) throws IOException { - FileSystemUtils.ensureDirectoryExists(outputDirectory.toFile()); - return outputDirectory.resolve(prefix + "-" + LocalDateTime.now().format(DATE_TIME_FORMATTER) + "." + extension) - .toFile(); - } - - /** - * Creates a new file with the given prefix, extension and current timestamp and ensures that the parent folder - * actually exists. One output folder is created per partition. - */ - public File createNewFileInPartitionOutputDirectory(String prefix, String extension) throws IOException { - Path partitionOutputDir = outputDirectory.resolve(safeFolderName(getTeamscaleServerOptions().partition)); - FileSystemUtils.ensureDirectoryExists(partitionOutputDir.toFile()); - return partitionOutputDir.resolve( - prefix + "-" + LocalDateTime.now().format(DATE_TIME_FORMATTER) + "." + extension).toFile(); - } - - private static Path safeFolderName(String folderName) { - String result = folderName.replaceAll("[<>:\"/|?*]", "") - .replaceAll("\\.+", "dot") - .replaceAll("\\x00", "") - .replaceAll("[. ]$", ""); - - if (result.isEmpty()) { - return Paths.get("default"); - } else { - return Paths.get(result); - } - } - - /** - * Sets the parent of the output directory for this run. The output directory itself will be created in this folder - * is named after the current timestamp with the format yyyy-MM-dd-HH-mm-ss.SSS - */ - public void setParentOutputDirectory(Path outputDirectoryParent) { - outputDirectory = outputDirectoryParent.resolve(LocalDateTime.now().format(DATE_TIME_FORMATTER)); - } - - /** - * @see #dumpIntervalInMinutes - */ - public int getDumpIntervalInMinutes() { - return dumpIntervalInMinutes; - } - - /** - * @see #duplicateClassFileBehavior - */ - public EDuplicateClassFileBehavior getDuplicateClassFileBehavior() { - return duplicateClassFileBehavior; - } - - /** Returns whether the config indicates to use Test Impact mode. */ - public boolean useTestwiseCoverageMode() { - return mode == EMode.TESTWISE; - } - - /** - * Returns the port at which the http server should listen for test execution information or null if disabled. - */ - public Integer getHttpServerPort() { - return httpServerPort; - } - - /** - * @see #loggingConfig - */ - public Path getLoggingConfig() { - return loggingConfig; - } - - /** - * @see #validateSsl - */ - public boolean shouldValidateSsl() { - return validateSsl; - } - - /** - * @see #obfuscateSecurityRelatedOutputs - */ - public boolean shouldObfuscateSecurityRelatedOutputs() { - return obfuscateSecurityRelatedOutputs; - } - - /** - * @see #jacocoIncludes - * @see #jacocoExcludes - */ - public ClasspathWildcardIncludeFilter getLocationIncludeFilter() { - return new ClasspathWildcardIncludeFilter(jacocoIncludes, jacocoExcludes); - } - - /** Whether coverage should be dumped in regular intervals. */ - public boolean shouldDumpInIntervals() { - return dumpIntervalInMinutes > 0; - } - - /** Whether coverage should be dumped on JVM shutdown. */ - public boolean shouldDumpOnExit() { - return shouldDumpOnExit; - } - - public ETestwiseCoverageMode getTestwiseCoverageMode() { - return testwiseCoverageMode; - } - - /** @see #ignoreUncoveredClasses */ - public boolean shouldIgnoreUncoveredClasses() { - return ignoreUncoveredClasses; - } - - /** @return the {@link TeamscaleProxyOptions} for the given protocol. */ - public TeamscaleProxyOptions getTeamscaleProxyOptions(ProxySystemProperties.Protocol protocol) { - if (protocol == ProxySystemProperties.Protocol.HTTP) { - return teamscaleProxyOptionsForHttp; - } - return teamscaleProxyOptionsForHttps; - } -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/options/AgentOptionsParser.java b/agent/src/main/java/com/teamscale/jacoco/agent/options/AgentOptionsParser.java deleted file mode 100644 index 2e6b9821d..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/options/AgentOptionsParser.java +++ /dev/null @@ -1,551 +0,0 @@ -/*-------------------------------------------------------------------------+ -| | -| Copyright (c) 2009-2018 CQSE GmbH | -| | -+-------------------------------------------------------------------------*/ -package com.teamscale.jacoco.agent.options; - -import com.teamscale.client.FileSystemUtils; -import com.teamscale.client.HttpUtils; -import com.teamscale.client.ProxySystemProperties; -import com.teamscale.client.StringUtils; -import com.teamscale.client.TeamscaleProxySystemProperties; -import com.teamscale.jacoco.agent.commandline.Validator; -import com.teamscale.jacoco.agent.configuration.AgentOptionReceiveException; -import com.teamscale.jacoco.agent.configuration.ConfigurationViaTeamscale; -import com.teamscale.jacoco.agent.options.sapnwdi.SapNwdiApplication; -import com.teamscale.jacoco.agent.upload.artifactory.ArtifactoryConfig; -import com.teamscale.jacoco.agent.upload.azure.AzureFileStorageConfig; -import com.teamscale.jacoco.agent.upload.teamscale.TeamscaleConfig; -import com.teamscale.report.EDuplicateClassFileBehavior; -import com.teamscale.report.util.ILogger; -import kotlin.Pair; -import okhttp3.HttpUrl; -import org.jetbrains.annotations.Nullable; -import org.jetbrains.annotations.VisibleForTesting; - -import java.io.File; -import java.io.FileNotFoundException; -import java.io.IOException; -import java.nio.file.InvalidPathException; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.time.format.DateTimeFormatter; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.stream.Collectors; - -import static com.teamscale.jacoco.agent.upload.artifactory.ArtifactoryConfig.ARTIFACTORY_GIT_PROPERTIES_COMMIT_DATE_FORMAT_OPTION; -import static com.teamscale.jacoco.agent.upload.artifactory.ArtifactoryConfig.ARTIFACTORY_GIT_PROPERTIES_JAR_OPTION; -import static java.util.stream.Collectors.joining; - -/** - * Parses agent command line options. - */ -public class AgentOptionsParser { - - /** The name of the option for providing the logging config. */ - public static final String LOGGING_CONFIG_OPTION = "logging-config"; - - /** The name of the option for providing the config file. */ - public static final String CONFIG_FILE_OPTION = "config-file"; - - /** Character which starts a comment in the config file. */ - private static final String COMMENT_PREFIX = "#"; - - /** The name of the option that enables debug logging. */ - public static final String DEBUG = "debug"; - - private final ILogger logger; - private final FilePatternResolver filePatternResolver; - private final TeamscaleConfig teamscaleConfig; - private final String environmentConfigId; - private final String environmentConfigFile; - @Nullable - private final TeamscaleCredentials credentials; - @Nullable - private final String environmentAccessToken; - private final List collectedErrors; - - /** - * Parses the given command-line options. - * - * @param environmentConfigId The Profiler configuration ID given via an environment variable. - * @param environmentConfigFile The Profiler configuration file given via an environment variable. - * @param credentials Optional Teamscale credentials from a teamscale.properties file. - * @param environmentAccessToken Optional access token for accessing Teamscale, read from an env variable. - */ - public static Pair> parse(String optionsString, String environmentConfigId, - String environmentConfigFile, @Nullable TeamscaleCredentials credentials, - @Nullable String environmentAccessToken, - ILogger logger) throws AgentOptionParseException, AgentOptionReceiveException { - AgentOptionsParser parser = new AgentOptionsParser(logger, environmentConfigId, environmentConfigFile, - credentials, environmentAccessToken); - AgentOptions options = parser.parse(optionsString); - return new Pair<>(options, parser.getCollectedErrors()); - } - - @VisibleForTesting - AgentOptionsParser(ILogger logger, String environmentConfigId, String environmentConfigFile, - @Nullable TeamscaleCredentials credentials, @Nullable String environmentAccessToken) { - this.logger = logger; - this.filePatternResolver = new FilePatternResolver(logger); - this.teamscaleConfig = new TeamscaleConfig(logger, filePatternResolver); - this.environmentConfigId = environmentConfigId; - this.environmentConfigFile = environmentConfigFile; - this.credentials = credentials; - this.environmentAccessToken = environmentAccessToken; - this.collectedErrors = new ArrayList<>(); - } - - private List getCollectedErrors() { - return collectedErrors; - } - - /** - * Throw the first collected exception, if present. - */ - @VisibleForTesting - public void throwOnCollectedErrors() throws Exception { - for (Exception e : collectedErrors) { - throw e; - } - } - - /** - * Parses the given command-line options. - */ - /* package */ AgentOptions parse( - String optionsString) throws AgentOptionParseException, AgentOptionReceiveException { - - if (optionsString == null) { - optionsString = ""; - } - logger.debug("Parsing options: " + optionsString); - - AgentOptions options = new AgentOptions(logger); - options.originalOptionsString = optionsString; - - presetCredentialOptions(options); - - if (!StringUtils.isEmpty(optionsString)) { - String[] optionParts = optionsString.split(","); - for (String optionPart : optionParts) { - try { - handleOptionPart(options, optionPart); - } catch (Exception e) { - collectedErrors.add(e); - } - } - } - - // we have to put the proxy options into system properties before reading the configuration from Teamscale as we - // might need them to connect to Teamscale - putTeamscaleProxyOptionsIntoSystemProperties(options); - - handleConfigId(options); - handleConfigFile(options); - - Validator validator = options.getValidator(); - if (!validator.isValid()) { - collectedErrors.add(new AgentOptionParseException("Invalid options given: " + validator.getErrorMessage())); - } - - return options; - } - - private void presetCredentialOptions(AgentOptions options) { - if (credentials != null) { - options.teamscaleServer.url = credentials.url; - options.teamscaleServer.userName = credentials.userName; - options.teamscaleServer.userAccessToken = credentials.accessKey; - } - if (environmentAccessToken != null) { - options.teamscaleServer.userAccessToken = environmentAccessToken; - } - } - - /** - * Stores the agent options for proxies in the {@link TeamscaleProxySystemProperties} and overwrites the password - * with the password found in the proxy-password-file if necessary. - */ - @VisibleForTesting - public static void putTeamscaleProxyOptionsIntoSystemProperties(AgentOptions options) { - options.getTeamscaleProxyOptions(ProxySystemProperties.Protocol.HTTP) - .putTeamscaleProxyOptionsIntoSystemProperties(); - options.getTeamscaleProxyOptions(ProxySystemProperties.Protocol.HTTPS) - .putTeamscaleProxyOptionsIntoSystemProperties(); - } - - private void handleConfigId(AgentOptions options) throws AgentOptionReceiveException, AgentOptionParseException { - if (environmentConfigId != null) { - if (options.teamscaleServer.configId != null) { - logger.warn( - "You specified an ID for a profiler configuration in Teamscale both in the agent options and using an environment variable." + - " The environment variable will override the ID specified using the agent options." + - " Please use one or the other."); - } - handleOptionPart(options, "config-id=" + environmentConfigId); - } - - readConfigFromTeamscale(options); - } - - private void handleConfigFile(AgentOptions options) throws AgentOptionParseException { - if (environmentConfigFile != null) { - handleOptionPart(options, "config-file=" + environmentConfigFile); - } - - if (environmentConfigId != null && environmentConfigFile != null) { - logger.warn("You specified both an ID for a profiler configuration in Teamscale and a config file." + - " The config file will override the Teamscale configuration." + - " Please use one or the other."); - } - } - - /** - * Parses and stores the given option in the format key=value. - */ - private void handleOptionPart(AgentOptions options, - String optionPart) throws AgentOptionParseException { - Pair keyAndValue = parseOption(optionPart); - handleOption(options, keyAndValue.getFirst(), keyAndValue.getSecond()); - } - - /** - * Parses and stores the option with the given key and value. - */ - private void handleOption(AgentOptions options, - String key, String value) throws AgentOptionParseException { - if (key.startsWith(DEBUG)) { - handleDebugOption(options, value); - return; - } - if (key.startsWith("jacoco-")) { - options.additionalJacocoOptions.add(new kotlin.Pair<>(key.substring(7), value)); - return; - } - if (key.startsWith("teamscale-") && teamscaleConfig.handleTeamscaleOptions(options.teamscaleServer, key, - value)) { - return; - } - if (key.startsWith("artifactory-") && ArtifactoryConfig - .handleArtifactoryOptions(options.artifactoryConfig, key, value)) { - return; - } - if (key.startsWith("azure-") && AzureFileStorageConfig - .handleAzureFileStorageOptions(options.azureFileStorageConfig, key, - value)) { - return; - } - if (key.startsWith("proxy-") && handleProxyOptions(options, StringUtils.stripPrefix(key, "proxy-"), value - )) { - return; - } - if (handleAgentOptions(options, key, value)) { - return; - } - throw new AgentOptionParseException("Unknown option: " + key); - } - - private boolean handleProxyOptions(AgentOptions options, String key, - String value) throws AgentOptionParseException { - String httpsPrefix = ProxySystemProperties.Protocol.HTTPS + "-"; - if (key.startsWith(httpsPrefix) - && options.getTeamscaleProxyOptions(ProxySystemProperties.Protocol.HTTPS) - .handleTeamscaleProxyOptions(StringUtils.stripPrefix( - key, httpsPrefix), value)) { - return true; - } - - String httpPrefix = ProxySystemProperties.Protocol.HTTP + "-"; - if (key.startsWith(httpPrefix) - && options.getTeamscaleProxyOptions(ProxySystemProperties.Protocol.HTTP) - .handleTeamscaleProxyOptions(StringUtils.stripPrefix( - key, httpPrefix), value)) { - return true; - } - - if (key.equals("password-file")) { - Path proxyPasswordPath = parsePath(filePatternResolver, key, value); - options.getTeamscaleProxyOptions(ProxySystemProperties.Protocol.HTTPS) - .setProxyPasswordPath(proxyPasswordPath); - options.getTeamscaleProxyOptions(ProxySystemProperties.Protocol.HTTP) - .setProxyPasswordPath(proxyPasswordPath); - return true; - } - return false; - } - - /** Parses and stores the debug logging file path if given. */ - private void handleDebugOption(AgentOptions options, String value) { - if (value.equalsIgnoreCase("false")) { - return; - } - options.debugLogging = true; - if (!value.isEmpty() && !value.equalsIgnoreCase("true")) { - options.debugLogDirectory = Paths.get(value); - } - } - - private Pair parseOption(String optionPart) throws AgentOptionParseException { - String[] keyAndValue = optionPart.split("=", 2); - if (keyAndValue.length < 2) { - throw new AgentOptionParseException("Got an option without any value: " + optionPart); - } - - String key = keyAndValue[0].toLowerCase(); - String value = keyAndValue[1]; - - // Remove quotes, which may be used to pass arguments with spaces via - // the command line - if (value.startsWith("\"") && value.endsWith("\"")) { - value = value.substring(1, value.length() - 1); - } - return new Pair<>(key, value); - } - - /** - * Handles all common command line options for the agent. - * - * @return true if it has successfully processed the given option. - */ - private boolean handleAgentOptions(AgentOptions options, String key, String value) - throws AgentOptionParseException { - switch (key) { - case "config-id": - options.teamscaleServer.configId = value; - return true; - case CONFIG_FILE_OPTION: - readConfigFromFile(options, parsePath(filePatternResolver, key, value).toFile()); - return true; - case LOGGING_CONFIG_OPTION: - options.loggingConfig = parsePath(filePatternResolver, key, value); - return true; - case "interval": - options.dumpIntervalInMinutes = parseInt(key, value); - return true; - case "validate-ssl": - options.validateSsl = Boolean.parseBoolean(value); - return true; - case "out": - options.setParentOutputDirectory(parsePath(filePatternResolver, key, value)); - return true; - case "upload-metadata": - try { - options.additionalMetaDataFiles = splitMultiOptionValue(value).stream().map(Paths::get).collect( - Collectors.toList()); - } catch (InvalidPathException e) { - throw new AgentOptionParseException("Invalid path given for option 'upload-metadata'", e); - } - return true; - case "duplicates": - options.duplicateClassFileBehavior = parseEnumValue(key, value, EDuplicateClassFileBehavior.class); - return true; - case "ignore-uncovered-classes": - options.ignoreUncoveredClasses = Boolean.parseBoolean(value); - return true; - case "obfuscate-security-related-outputs": - options.obfuscateSecurityRelatedOutputs = Boolean.parseBoolean(value); - return true; - case "dump-on-exit": - options.shouldDumpOnExit = Boolean.parseBoolean(value); - return true; - case "search-git-properties-recursively": - options.searchGitPropertiesRecursively = Boolean.parseBoolean(value); - return true; - case ARTIFACTORY_GIT_PROPERTIES_JAR_OPTION: - logger.warn( - "The option " + ARTIFACTORY_GIT_PROPERTIES_JAR_OPTION + " is deprecated. It still has an effect, " + - "but should be replaced with the equivalent option " + AgentOptions.GIT_PROPERTIES_JAR_OPTION + "."); - // intended fallthrough (acts as alias) - case AgentOptions.GIT_PROPERTIES_JAR_OPTION: - options.gitPropertiesJar = getGitPropertiesJarFile(value); - return true; - case ARTIFACTORY_GIT_PROPERTIES_COMMIT_DATE_FORMAT_OPTION: - logger.warn( - "The option " + ARTIFACTORY_GIT_PROPERTIES_COMMIT_DATE_FORMAT_OPTION + " is deprecated. It still has an effect, " + - "but should be replaced with the equivalent option " + AgentOptions.GIT_PROPERTIES_COMMIT_DATE_FORMAT_OPTION + "."); - // intended fallthrough (acts as alias) - case AgentOptions.GIT_PROPERTIES_COMMIT_DATE_FORMAT_OPTION: - options.gitPropertiesCommitTimeFormat = DateTimeFormatter.ofPattern(value); - return true; - case "mode": - options.mode = parseEnumValue(key, value, EMode.class); - return true; - case "includes": - options.jacocoIncludes = value.replaceAll(";", ":"); - return true; - case "excludes": - options.jacocoExcludes = value.replaceAll(";", ":") + ":" + AgentOptions.DEFAULT_EXCLUDES; - return true; - case "class-dir": - List list = splitMultiOptionValue(value); - try { - options.classDirectoriesOrZips = ClasspathUtils.resolveClasspathTextFiles(key, filePatternResolver, - list); - } catch (IOException e) { - throw new AgentOptionParseException(e); - } - return true; - case "http-server-port": - options.httpServerPort = parseInt(key, value); - return true; - case "sap-nwdi-applications": - options.sapNetWeaverJavaApplications = SapNwdiApplication.parseApplications(value); - return true; - case "tia-mode": - options.testwiseCoverageMode = AgentOptionsParser.parseEnumValue(key, value, - ETestwiseCoverageMode.class); - return true; - default: - return false; - } - } - - private void readConfigFromTeamscale( - AgentOptions options) throws AgentOptionParseException, AgentOptionReceiveException { - if (options.teamscaleServer.configId == null) { - return; - } - if (!options.teamscaleServer.isConfiguredForServerConnection()) { - throw new AgentOptionParseException( - "Config-id '" + options.teamscaleServer.configId + "' specified without teamscale url/user/accessKey! These options must be provided locally via config-file or command line argument."); - } - // Set ssl validation option in case it needs to be off before trying to reach Teamscale. - HttpUtils.setShouldValidateSsl(options.shouldValidateSsl()); - ConfigurationViaTeamscale configuration = ConfigurationViaTeamscale.retrieve(logger, - options.teamscaleServer.configId, - options.teamscaleServer.url, - options.teamscaleServer.userName, - options.teamscaleServer.userAccessToken); - options.configurationViaTeamscale = configuration; - logger.debug( - "Received the following options from Teamscale: " + configuration.getProfilerConfiguration().configurationOptions); - readConfigFromString(options, configuration.getProfilerConfiguration().configurationOptions); - } - - private File getGitPropertiesJarFile(String path) { - File jarFile = new File(path); - if (!jarFile.exists()) { - logger.warn( - "The path provided with the " + AgentOptions.GIT_PROPERTIES_JAR_OPTION + " option does not exist: " + path + ". Continuing without searching it for git.properties files."); - return null; - } - if (!jarFile.isFile()) { - logger.warn( - "The path provided with the " + AgentOptions.GIT_PROPERTIES_JAR_OPTION + " option is not a regular file (probably a folder instead): " + path + ". Continuing without searching it for git.properties files."); - return null; - } - return jarFile; - } - - /** - * Parses the given value as an enum constant case-insensitively and converts "-" to "_". - */ - public static > T parseEnumValue(String key, String value, Class enumClass) - throws AgentOptionParseException { - try { - return Enum.valueOf(enumClass, value.toUpperCase().replaceAll("-", "_")); - } catch (IllegalArgumentException e) { - String validValues = Arrays.stream(enumClass.getEnumConstants()).map(T::name).collect(joining(", ")); - throw new AgentOptionParseException("Invalid value for option `" + key + "`. Valid values: " + validValues, - e); - } - } - - /** - * Reads configuration parameters from the given file. The expected format is basically the same as for the command - * line, but line breaks are also considered as separators. e.g. class-dir=out # Some comment includes=test.* - * excludes=third.party.* - */ - private void readConfigFromFile(AgentOptions options, - File configFile) throws AgentOptionParseException { - try { - String content = FileSystemUtils.readFileUTF8(configFile); - readConfigFromString(options, content); - } catch (FileNotFoundException e) { - throw new AgentOptionParseException( - "File " + configFile.getAbsolutePath() + " given for option 'config-file' not found", e); - } catch (IOException e) { - throw new AgentOptionParseException( - "An error occurred while reading the config file " + configFile.getAbsolutePath(), e); - } - } - - private void readConfigFromString(AgentOptions options, String content) { - List configFileKeyValues = StringUtils.splitLinesAsList(content); - for (String optionKeyValue : configFileKeyValues) { - try { - String trimmedOption = optionKeyValue.trim(); - if (trimmedOption.isEmpty() || trimmedOption.startsWith(COMMENT_PREFIX)) { - continue; - } - handleOptionPart(options, optionKeyValue); - } catch (Exception e) { - collectedErrors.add(e); - } - } - } - - private int parseInt(String key, String value) throws AgentOptionParseException { - try { - return Integer.parseInt(value); - } catch (NumberFormatException e) { - throw new AgentOptionParseException("Invalid non-numeric value for option `" + key + "`: " + value); - } - } - - /** - * Interprets the given pattern as an Ant pattern and resolves it to one existing {@link Path}. If the given path is - * relative, it is resolved relative to the current working directory. If more than one file matches the pattern, - * one of the matching files is used without any guarantees as to which. The selection is, however, guaranteed to be - * deterministic, i.e. if you run the pattern twice and get the same set of files, the same file will be picked each - * time. - */ - public static Path parsePath(FilePatternResolver filePatternResolver, String optionName, - String pattern) throws AgentOptionParseException { - try { - return filePatternResolver.parsePath(optionName, pattern); - } catch (IOException e) { - throw new AgentOptionParseException(e); - } - } - - /** - * Parses the given value as a URL. - */ - public static HttpUrl parseUrl(String key, String value) throws AgentOptionParseException { - if (!value.endsWith("/")) { - value += "/"; - } - - // default to HTTP if no scheme is given and port is not 443, default to HTTPS if no scheme is given AND port is 443 - if (!value.startsWith("http://") && !value.startsWith("https://")) { - HttpUrl url = getUrl(key, "http://" + value); - if (url.port() == 443) { - value = "https://" + value; - } else { - value = "http://" + value; - } - } - - return getUrl(key, value); - } - - private static HttpUrl getUrl(String key, String value) throws AgentOptionParseException { - HttpUrl url = HttpUrl.parse(value); - if (url == null) { - throw new AgentOptionParseException("Invalid URL given for option '" + key + "'"); - } - return url; - } - - /** - * Splits the given value at semicolons. - */ - private static List splitMultiOptionValue(String value) { - return Arrays.asList(value.split(";")); - } -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/options/ETestwiseCoverageMode.java b/agent/src/main/java/com/teamscale/jacoco/agent/options/ETestwiseCoverageMode.java deleted file mode 100644 index d978c2bf2..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/options/ETestwiseCoverageMode.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.teamscale.jacoco.agent.options; - -import com.teamscale.jacoco.agent.testimpact.TestEventHandlerStrategyBase; - -/** Decides which {@link TestEventHandlerStrategyBase} is used in testwise mode. */ -public enum ETestwiseCoverageMode { - /** Caches testwise coverage in-memory and uploads a report to Teamscale. */ - TEAMSCALE_UPLOAD, - /** Writes testwise coverage to disk as .json files. */ - DISK, - /** Writes testwise coverage to disk as .exec files. */ - EXEC_FILE, - /** Returns testwise coverage to the caller via HTTP. */ - HTTP -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/options/JacocoAgentOptionsBuilder.java b/agent/src/main/java/com/teamscale/jacoco/agent/options/JacocoAgentOptionsBuilder.java deleted file mode 100644 index 56f2e84e7..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/options/JacocoAgentOptionsBuilder.java +++ /dev/null @@ -1,96 +0,0 @@ -package com.teamscale.jacoco.agent.options; - -import com.teamscale.jacoco.agent.logging.LoggingUtils; -import com.teamscale.jacoco.agent.util.AgentUtils; -import org.slf4j.Logger; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Collections; - -/** Builder for the JaCoCo agent options string. */ -public class JacocoAgentOptionsBuilder { - private final Logger logger = LoggingUtils.getLogger(this); - - private final AgentOptions agentOptions; - - public JacocoAgentOptionsBuilder(AgentOptions agentOptions) { - this.agentOptions = agentOptions; - } - - /** - * Returns the options to pass to the JaCoCo agent. - */ - public String createJacocoAgentOptions() throws AgentOptionParseException, IOException { - StringBuilder builder = new StringBuilder(getModeSpecificOptions()); - if (agentOptions.jacocoIncludes != null) { - builder.append(",includes=").append(agentOptions.jacocoIncludes); - } - if (agentOptions.jacocoExcludes != null) { - logger.debug("Using default excludes: " + AgentOptions.DEFAULT_EXCLUDES); - builder.append(",excludes=").append(agentOptions.jacocoExcludes); - } - - // Don't dump class files in testwise mode when coverage is written to an exec file - boolean needsClassFiles = agentOptions.mode == EMode.NORMAL || agentOptions.testwiseCoverageMode != ETestwiseCoverageMode.EXEC_FILE; - if (agentOptions.classDirectoriesOrZips.isEmpty() && needsClassFiles) { - Path tempDir = createTemporaryDumpDirectory(); - tempDir.toFile().deleteOnExit(); - builder.append(",classdumpdir=").append(tempDir.toAbsolutePath()); - - agentOptions.classDirectoriesOrZips = Collections.singletonList(tempDir.toFile()); - } - - agentOptions.additionalJacocoOptions - .forEach(pair -> builder.append(",").append(pair.getFirst()).append("=").append(pair.getSecond())); - - return builder.toString(); - } - - private Path createTemporaryDumpDirectory() throws AgentOptionParseException { - try { - return Files.createDirectory(AgentUtils.getMainTempDirectory().resolve("jacoco-class-dump")); - } catch (IOException e) { - logger.warn("Unable to create temporary directory in default location. Trying in system temp directory."); - } - - try { - return Files.createTempDirectory("jacoco-class-dump"); - } catch (IOException e) { - logger.warn("Unable to create temporary directory in default location. Trying in output directory."); - } - - try { - return Files.createTempDirectory(agentOptions.getOutputDirectory(), "jacoco-class-dump"); - } catch (IOException e) { - logger.warn("Unable to create temporary directory in output directory. Trying in agent's directory."); - } - - Path agentDirectory = AgentUtils.getAgentDirectory(); - if (agentDirectory == null) { - throw new AgentOptionParseException("Could not resolve directory that contains the agent"); - } - try { - return Files.createTempDirectory(agentDirectory, "jacoco-class-dump"); - } catch (IOException e) { - throw new AgentOptionParseException("Unable to create a temporary directory anywhere", e); - } - } - - /** - * Returns additional options for JaCoCo depending on the selected {@link AgentOptions#mode} and - * {@link AgentOptions#testwiseCoverageMode}. - */ - String getModeSpecificOptions() throws IOException { - if (agentOptions - .useTestwiseCoverageMode() && agentOptions.testwiseCoverageMode == ETestwiseCoverageMode.EXEC_FILE) { - // when writing to a .exec file, we can instruct JaCoCo to do so directly - return "destfile=" + agentOptions.createNewFileInOutputDirectory("jacoco", "exec").getAbsolutePath(); - } else { - // otherwise we don't need JaCoCo to perform any output of the .exec information - return "output=none"; - } - } - -} 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/options/TeamscaleCredentials.java b/agent/src/main/java/com/teamscale/jacoco/agent/options/TeamscaleCredentials.java deleted file mode 100644 index 254930b0c..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/options/TeamscaleCredentials.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.teamscale.jacoco.agent.options; - -import okhttp3.HttpUrl; - -/** Credentials for accessing a Teamscale instance. */ -public class TeamscaleCredentials { - - /** The URL of the Teamscale server. */ - public final HttpUrl url; - - /** The user name used to authenticate against Teamscale. */ - public final String userName; - - /** The user's access key. */ - public final String accessKey; - - public TeamscaleCredentials(HttpUrl url, String userName, String userAccessToken) { - this.url = url; - this.userName = userName; - this.accessKey = userAccessToken; - } -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/options/TeamscalePropertiesUtils.java b/agent/src/main/java/com/teamscale/jacoco/agent/options/TeamscalePropertiesUtils.java deleted file mode 100644 index 15de0a912..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/options/TeamscalePropertiesUtils.java +++ /dev/null @@ -1,76 +0,0 @@ -package com.teamscale.jacoco.agent.options; - -import com.teamscale.client.FileSystemUtils; -import com.teamscale.jacoco.agent.util.AgentUtils; -import okhttp3.HttpUrl; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Properties; - -/** - * Utilities for working with the teamscale.properties file that contains access credentials for the Teamscale - * instance. - */ -public class TeamscalePropertiesUtils { - - private static final Path TEAMSCALE_PROPERTIES_PATH = AgentUtils.getAgentDirectory() - .resolve("teamscale.properties"); - - /** - * Tries to open {@link #TEAMSCALE_PROPERTIES_PATH} and parse that properties file to obtain - * {@link TeamscaleCredentials}. - * - * @return the parsed credentials or null in case the teamscale.properties file doesn't exist. - * @throws AgentOptionParseException in case the teamscale.properties file exists but can't be read or parsed. - */ - public static TeamscaleCredentials parseCredentials() throws AgentOptionParseException { - return parseCredentials(TEAMSCALE_PROPERTIES_PATH); - } - - /** - * Same as {@link #parseCredentials()} but testable since the path is not hardcoded. - */ - /*package*/ - static TeamscaleCredentials parseCredentials( - Path teamscalePropertiesPath) throws AgentOptionParseException { - if (!Files.exists(teamscalePropertiesPath)) { - return null; - } - - try { - Properties properties = FileSystemUtils.readProperties(teamscalePropertiesPath.toFile()); - return parseProperties(properties); - } catch (IOException e) { - throw new AgentOptionParseException("Failed to read " + teamscalePropertiesPath, e); - } - } - - private static TeamscaleCredentials parseProperties(Properties properties) throws AgentOptionParseException { - String urlString = properties.getProperty("url"); - if (urlString == null) { - throw new AgentOptionParseException("teamscale.properties is missing the url field"); - } - - HttpUrl url; - try { - url = HttpUrl.get(urlString); - } catch (IllegalArgumentException e) { - throw new AgentOptionParseException("teamscale.properties contained malformed URL " + urlString, e); - } - - String userName = properties.getProperty("username"); - if (userName == null) { - throw new AgentOptionParseException("teamscale.properties is missing the username field"); - } - - String accessKey = properties.getProperty("accesskey"); - if (accessKey == null) { - throw new AgentOptionParseException("teamscale.properties is missing the accesskey field"); - } - - return new TeamscaleCredentials(url, userName, accessKey); - } - -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/options/TeamscaleProxyOptions.java b/agent/src/main/java/com/teamscale/jacoco/agent/options/TeamscaleProxyOptions.java deleted file mode 100644 index eddbcee2c..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/options/TeamscaleProxyOptions.java +++ /dev/null @@ -1,122 +0,0 @@ -package com.teamscale.jacoco.agent.options; - -import com.teamscale.client.FileSystemUtils; -import com.teamscale.client.ProxySystemProperties; -import com.teamscale.client.StringUtils; -import com.teamscale.client.TeamscaleProxySystemProperties; -import com.teamscale.report.util.ILogger; - -import java.io.IOException; -import java.nio.file.Path; - -/** - * Parses agent command line options related to the proxy settings. - */ -public class TeamscaleProxyOptions { - - private final ILogger logger; - - /** The host of the proxy server. */ - /* package */ String proxyHost; - - /** The port of the proxy server. */ - /* package */ int proxyPort; - - /** The password for the proxy user. */ - /* package */ String proxyPassword; - - public void setProxyPasswordPath(Path proxyPasswordPath) { - this.proxyPasswordPath = proxyPasswordPath; - } - - /** A path to the file that contains the password for the proxy authentication. */ - /* package */ Path proxyPasswordPath; - - /** The username of the proxy user. */ - /* package */ String proxyUser; - - private final ProxySystemProperties.Protocol protocol; - - /** Constructor. */ - public TeamscaleProxyOptions(ProxySystemProperties.Protocol protocol, ILogger logger) { - this.protocol = protocol; - this.logger = logger; - ProxySystemProperties proxySystemProperties = new ProxySystemProperties(protocol); - proxyHost = proxySystemProperties.getProxyHost(); - try { - proxyPort = proxySystemProperties.getProxyPort(); - } catch (ProxySystemProperties.IncorrectPortFormatException e) { - proxyPort = -1; - logger.warn(e.getMessage()); - } - proxyUser = proxySystemProperties.getProxyUser(); - proxyPassword = proxySystemProperties.getProxyPassword(); - } - - /** - * Processes the command-line options for proxies. - * - * @return true if it has successfully processed the given option. - */ - public boolean handleTeamscaleProxyOptions(String key, String value) throws AgentOptionParseException { - if ("host".equals(key)) { - proxyHost = value; - return true; - } - String proxyPortOption = "port"; - if (proxyPortOption.equals(key)) { - try { - proxyPort = Integer.parseInt(value); - } catch (NumberFormatException e) { - throw new AgentOptionParseException("Could not parse proxy port \"" + value + - "\" set via \"" + proxyPortOption + "\"", e); - } - return true; - } - if ("user".equals(key)) { - proxyUser = value; - return true; - } else if ("password".equals(key)) { - proxyPassword = value; - return true; - } - return false; - } - - /** Stores the teamscale-specific proxy settings as system properties to make them always available. */ - public void putTeamscaleProxyOptionsIntoSystemProperties() { - TeamscaleProxySystemProperties teamscaleProxySystemProperties = new TeamscaleProxySystemProperties(protocol); - if (!StringUtils.isEmpty(proxyHost)) { - teamscaleProxySystemProperties.setProxyHost(proxyHost); - } - if (proxyPort > 0) { - teamscaleProxySystemProperties.setProxyPort(proxyPort); - } - if (!StringUtils.isEmpty(proxyUser)) { - teamscaleProxySystemProperties.setProxyUser(proxyUser); - } - if (!StringUtils.isEmpty(proxyPassword)) { - teamscaleProxySystemProperties.setProxyPassword(proxyPassword); - } - - setProxyPasswordFromFile(proxyPasswordPath); - } - - /** - * Sets the proxy password JVM property from a file for the protocol in this instance of - * {@link TeamscaleProxyOptions}. - */ - private void setProxyPasswordFromFile(Path proxyPasswordFilePath) { - if (proxyPasswordFilePath == null) { - return; - } - try { - String proxyPassword = FileSystemUtils.readFileUTF8(proxyPasswordFilePath.toFile()).trim(); - new TeamscaleProxySystemProperties(protocol).setProxyPassword(proxyPassword); - } catch (IOException e) { - logger.error( - "Unable to open file containing proxy password. Please make sure the file exists and the user has the permissions to read the file.", - e); - } - } -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/options/sapnwdi/DelayedSapNwdiMultiUploader.java b/agent/src/main/java/com/teamscale/jacoco/agent/options/sapnwdi/DelayedSapNwdiMultiUploader.java deleted file mode 100644 index 74b84d6bb..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/options/sapnwdi/DelayedSapNwdiMultiUploader.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.teamscale.jacoco.agent.options.sapnwdi; - -import com.teamscale.client.CommitDescriptor; -import com.teamscale.jacoco.agent.upload.DelayedMultiUploaderBase; -import com.teamscale.jacoco.agent.upload.IUploader; - -import java.util.Collection; -import java.util.HashMap; -import java.util.Map; -import java.util.concurrent.Executor; -import java.util.function.BiFunction; - -/** - * Wraps multiple {@link IUploader}s in order to delay uploads until a {@link CommitDescriptor} is asynchronously made - * available for each application. Whenever a dump happens the coverage is uploaded to all projects for which a - * corresponding commit has already been found. Uploads for application that have not commit at that time are skipped. - *

- * This is safe assuming that the marker class is the central entry point for the application and therefore there should - * not be any relevant coverage for the application as long as the marker class has not been loaded. - */ -public class DelayedSapNwdiMultiUploader extends DelayedMultiUploaderBase implements IUploader { - - private final BiFunction uploaderFactory; - - /** The wrapped uploader instances. */ - private final Map uploaders = new HashMap<>(); - - /** - * Visible for testing. Allows tests to control the {@link Executor} to test the asynchronous functionality of this - * class. - */ - public DelayedSapNwdiMultiUploader( - BiFunction uploaderFactory) { - this.uploaderFactory = uploaderFactory; - registerShutdownHook(); - } - - /** Registers the shutdown hook. */ - private void registerShutdownHook() { - Runtime.getRuntime().addShutdownHook(new Thread(() -> { - if (getWrappedUploaders().isEmpty()) { - logger.error("The application was shut down before a commit could be found. The recorded coverage" + - " is lost."); - } - })); - } - - /** Sets the commit info detected for the application. */ - public void setCommitForApplication(CommitDescriptor commit, SapNwdiApplication application) { - logger.info("Found commit for " + application.markerClass + ": " + commit); - IUploader uploader = uploaderFactory.apply(commit, application); - uploaders.put(application, uploader); - } - - @Override - protected Collection getWrappedUploaders() { - return uploaders.values(); - } -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/options/sapnwdi/SapNwdiApplication.java b/agent/src/main/java/com/teamscale/jacoco/agent/options/sapnwdi/SapNwdiApplication.java deleted file mode 100644 index 5c3e85df5..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/options/sapnwdi/SapNwdiApplication.java +++ /dev/null @@ -1,83 +0,0 @@ -package com.teamscale.jacoco.agent.options.sapnwdi; - -import com.teamscale.jacoco.agent.options.AgentOptionParseException; - -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; - -/** - * An SAP application that is identified by a {@link #markerClass} and refers to a corresponding Teamscale project. - */ -public class SapNwdiApplication { - - /** Parses an application definition string e.g. "com.package.MyClass:projectId;com.company.Main:project". */ - public static List parseApplications(String applications) throws AgentOptionParseException { - List nwdiConfiguration = new ArrayList<>(); - String[] markerClassAndProjectPairs = applications.split(";"); - if (markerClassAndProjectPairs.length == 0) { - throw new AgentOptionParseException("Application definition is expected not to be empty."); - } - - for (String markerClassAndProjectPair : markerClassAndProjectPairs) { - if (markerClassAndProjectPair.trim().isEmpty()) { - throw new AgentOptionParseException("Application definition is expected not to be empty."); - } - String[] markerClassAndProject = markerClassAndProjectPair.split(":"); - if (markerClassAndProject.length != 2) { - throw new AgentOptionParseException( - "Application definition " + markerClassAndProjectPair + " is expected to contain a marker class and project separated by a colon."); - } - String markerClass = markerClassAndProject[0].trim(); - if (markerClass.isEmpty()) { - throw new AgentOptionParseException("Marker class is not given for " + markerClassAndProjectPair + "!"); - } - String teamscaleProject = markerClassAndProject[1].trim(); - if (teamscaleProject.isEmpty()) { - throw new AgentOptionParseException( - "Teamscale project is not given for " + markerClassAndProjectPair + "!"); - } - SapNwdiApplication nwdiApplication = new SapNwdiApplication(markerClass, teamscaleProject); - nwdiConfiguration.add(nwdiApplication); - } - return nwdiConfiguration; - } - - /** A fully qualified class name that is used to match a jar file to this application. */ - public final String markerClass; - - /** The teamscale project to which coverage should be uploaded. */ - public final String teamscaleProject; - - private SapNwdiApplication(String markerClass, String teamscaleProject) { - this.markerClass = markerClass; - this.teamscaleProject = teamscaleProject; - } - - /** @see #markerClass */ - public String getMarkerClass() { - return markerClass; - } - - /** @see #teamscaleProject */ - public String getTeamscaleProject() { - return teamscaleProject; - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - SapNwdiApplication that = (SapNwdiApplication) o; - return markerClass.equals(that.markerClass) && teamscaleProject.equals(that.teamscaleProject); - } - - @Override - public int hashCode() { - return Objects.hash(markerClass, teamscaleProject); - } -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/CoverageToDiskStrategy.java b/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/CoverageToDiskStrategy.java deleted file mode 100644 index 9dc8b0c01..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/CoverageToDiskStrategy.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.teamscale.jacoco.agent.testimpact; - -import com.teamscale.client.FileSystemUtils; -import com.teamscale.jacoco.agent.JacocoRuntimeController; -import com.teamscale.jacoco.agent.options.AgentOptions; -import com.teamscale.report.testwise.jacoco.JaCoCoTestwiseReportGenerator; - -import java.io.File; -import java.io.IOException; - -/** - * Strategy for appending coverage into one json test-wise coverage file with one session per test. - */ -public class CoverageToDiskStrategy extends CoverageToJsonStrategyBase { - - public CoverageToDiskStrategy(JacocoRuntimeController controller, AgentOptions agentOptions, - JaCoCoTestwiseReportGenerator reportGenerator) { - super(controller, agentOptions, reportGenerator); - } - - @Override - protected void handleTestwiseCoverageJsonReady(String json) throws IOException { - File reportFile = agentOptions.createNewFileInPartitionOutputDirectory("testwise-coverage", "json"); - FileSystemUtils.writeFileUTF8(reportFile, json); - } -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/CoverageToExecFileStrategy.java b/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/CoverageToExecFileStrategy.java deleted file mode 100644 index 4a61870ef..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/CoverageToExecFileStrategy.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.teamscale.jacoco.agent.testimpact; - -import com.teamscale.jacoco.agent.JacocoRuntimeController; -import com.teamscale.jacoco.agent.logging.LoggingUtils; -import com.teamscale.jacoco.agent.options.AgentOptions; -import com.teamscale.report.testwise.jacoco.cache.CoverageGenerationException; -import com.teamscale.report.testwise.model.TestExecution; -import com.teamscale.report.testwise.model.TestInfo; -import org.slf4j.Logger; - -import java.io.IOException; - -/** - * Strategy for appending coverage into one exec file with one session per test. Execution data will be stored in a json - * file side-by-side with the exec file. Test executions are also appended into a single file. - */ -public class CoverageToExecFileStrategy extends TestEventHandlerStrategyBase { - - private final Logger logger = LoggingUtils.getLogger(this); - - /** Helper for writing test executions to disk. */ - private final TestExecutionWriter testExecutionWriter; - - public CoverageToExecFileStrategy(JacocoRuntimeController controller, AgentOptions agentOptions, - TestExecutionWriter testExecutionWriter) { - super(agentOptions, controller); - this.testExecutionWriter = testExecutionWriter; - } - - @Override - public TestInfo testEnd(String test, - TestExecution testExecution) throws JacocoRuntimeController.DumpException, CoverageGenerationException { - logger.debug("Test {} ended with execution {}. Writing exec file and test execution", test, testExecution); - super.testEnd(test, testExecution); - controller.dump(); - // Ensures that the coverage collected between the last test and the JVM shutdown - // is not considered a test with the same name as the last test - controller.resetSessionId(); - if (testExecution != null) { - try { - testExecutionWriter.append(testExecution); - logger.debug("Successfully wrote test execution for {}", test); - } catch (IOException e) { - logger.error("Failed to store test execution: {}", e.getMessage(), e); - } - } - return null; - } -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/CoverageToJsonStrategyBase.java b/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/CoverageToJsonStrategyBase.java deleted file mode 100644 index 68ffe481e..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/CoverageToJsonStrategyBase.java +++ /dev/null @@ -1,152 +0,0 @@ -package com.teamscale.jacoco.agent.testimpact; - -import com.teamscale.client.ClusteredTestDetails; -import com.teamscale.client.JsonUtils; -import com.teamscale.client.PrioritizableTestCluster; -import com.teamscale.jacoco.agent.JacocoRuntimeController; -import com.teamscale.jacoco.agent.logging.LoggingUtils; -import com.teamscale.jacoco.agent.options.AgentOptions; -import com.teamscale.report.testwise.jacoco.JaCoCoTestwiseReportGenerator; -import com.teamscale.report.testwise.jacoco.cache.CoverageGenerationException; -import com.teamscale.report.testwise.model.TestExecution; -import com.teamscale.report.testwise.model.TestInfo; -import com.teamscale.report.testwise.model.TestwiseCoverage; -import com.teamscale.report.testwise.model.TestwiseCoverageReport; -import com.teamscale.report.testwise.model.builder.TestCoverageBuilder; -import com.teamscale.report.testwise.model.builder.TestwiseCoverageReportBuilder; -import org.slf4j.Logger; - -import java.io.File; -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; - -import static java.util.stream.Collectors.toList; - -/** - * Base for strategies that produce testwise coverage information in JSON and store or send this data further. - */ -public abstract class CoverageToJsonStrategyBase extends TestEventHandlerStrategyBase { - - /** - * The logger to use. - */ - protected final Logger logger = LoggingUtils.getLogger(this); - - /** - * The path to the exec file into which the coverage of the current test run is appended to. Will be null if there - * is no file for the current test run yet. - */ - private File testExecFile; - private final List testExecutions = new ArrayList<>(); - private List availableTests = new ArrayList<>(); - - private final JaCoCoTestwiseReportGenerator reportGenerator; - - public CoverageToJsonStrategyBase(JacocoRuntimeController controller, AgentOptions agentOptions, - JaCoCoTestwiseReportGenerator reportGenerator) { - super(agentOptions, controller); - this.reportGenerator = reportGenerator; - } - - @Override - public List testRunStart(List availableTests, - boolean includeNonImpactedTests, - boolean includeAddedTests, boolean includeFailedAndSkipped, - String baseline, String baselineRevision) throws IOException { - if (availableTests != null) { - this.availableTests = new ArrayList<>(availableTests); - } - return super.testRunStart(this.availableTests, includeNonImpactedTests, includeAddedTests, - includeFailedAndSkipped, baseline, baselineRevision); - } - - @Override - public void testStart(String uniformPath) { - super.testStart(uniformPath); - if (availableTests.stream().noneMatch(test -> test.uniformPath.equals(uniformPath))) { - // ensure that we can at least generate a report for the tests that were actually run, - // even if the caller did not provide a list of tests up-front in testRunStart - availableTests.add(new ClusteredTestDetails(uniformPath, uniformPath, null, null)); - } - } - - @Override - public TestInfo testEnd(String test, - TestExecution testExecution) throws JacocoRuntimeController.DumpException, CoverageGenerationException { - super.testEnd(test, testExecution); - if (testExecution != null) { - testExecutions.add(testExecution); - } - - try { - if (testExecFile == null) { - testExecFile = agentOptions.createNewFileInOutputDirectory("coverage", "exec"); - testExecFile.deleteOnExit(); - } - controller.dumpToFileAndReset(testExecFile); - } catch (IOException e) { - throw new JacocoRuntimeController.DumpException( - "Failed to write coverage to disk into " + testExecFile + "!", - e); - } - - return null; - } - - @Override - public void testRunEnd(boolean partial) throws IOException, CoverageGenerationException { - if (testExecFile == null) { - logger.warn("Tried to end a test run that contained no tests!"); - clearTestRun(); - return; - } - - String testwiseCoverageJson = createTestwiseCoverageReport(partial); - handleTestwiseCoverageJsonReady(testwiseCoverageJson); - } - - /** - * Hook that is invoked when the JSON is ready for processed further. - */ - protected abstract void handleTestwiseCoverageJsonReady(String json) throws IOException; - - /** - * Creates a testwise coverage report from the coverage collected in {@link #testExecFile} and the test execution - * information in {@link #testExecutions}. - */ - private String createTestwiseCoverageReport(boolean partial) throws IOException, CoverageGenerationException { - List executionUniformPaths = testExecutions.stream().map(execution -> { - if (execution == null) { - return null; - } else { - return execution.uniformPath; - } - }).collect(toList()); - - logger.debug( - "Creating testwise coverage from available tests `{}`, test executions `{}`, exec file and partial {}", - availableTests.stream().map(test -> test.uniformPath).collect(toList()), - executionUniformPaths, partial); - reportGenerator.updateClassDirCache(); - TestwiseCoverage testwiseCoverage = reportGenerator.convert(testExecFile); - logger.debug("Created testwise coverage report (containing coverage for tests `{}`)", - testwiseCoverage.getTests().values().stream().map(TestCoverageBuilder::getUniformPath) - .collect(toList())); - - TestwiseCoverageReport report = TestwiseCoverageReportBuilder.createFrom(availableTests, - testwiseCoverage.getTests().values(), testExecutions, partial); - - testExecFile.delete(); - testExecFile = null; - clearTestRun(); - - return JsonUtils.serializeToJson(report); - } - - private void clearTestRun() { - availableTests.clear(); - testExecutions.clear(); - } - -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/CoverageToTeamscaleStrategy.java b/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/CoverageToTeamscaleStrategy.java deleted file mode 100644 index 2c2641ff5..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/CoverageToTeamscaleStrategy.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.teamscale.jacoco.agent.testimpact; - -import com.teamscale.client.EReportFormat; -import com.teamscale.client.FileSystemUtils; -import com.teamscale.jacoco.agent.JacocoRuntimeController; -import com.teamscale.jacoco.agent.options.AgentOptions; -import com.teamscale.report.testwise.jacoco.JaCoCoTestwiseReportGenerator; - -import java.io.File; -import java.io.IOException; -import java.util.List; - -/** - * Strategy that records test-wise coverage and uploads the resulting report to Teamscale. Also handles the - * {@link #testRunStart(List, boolean, boolean, boolean, String, String)} event by retrieving tests to run from - * Teamscale. - */ -public class CoverageToTeamscaleStrategy extends CoverageToJsonStrategyBase { - - public CoverageToTeamscaleStrategy(JacocoRuntimeController controller, AgentOptions agentOptions, - JaCoCoTestwiseReportGenerator reportGenerator) { - super(controller, agentOptions, reportGenerator); - } - - @Override - protected void handleTestwiseCoverageJsonReady(String json) throws IOException { - try { - teamscaleClient - .uploadReport(EReportFormat.TESTWISE_COVERAGE, json, - agentOptions.getTeamscaleServerOptions().commit, - agentOptions.getTeamscaleServerOptions().revision, - agentOptions.getTeamscaleServerOptions().repository, - agentOptions.getTeamscaleServerOptions().partition, - agentOptions.getTeamscaleServerOptions().getMessage()); - } catch (IOException e) { - File reportFile = agentOptions.createNewFileInOutputDirectory("testwise-coverage", "json"); - FileSystemUtils.writeFileUTF8(reportFile, json); - String errorMessage = "Failed to upload coverage to Teamscale! Report is stored in " + reportFile + "!"; - logger.error(errorMessage, e); - throw new IOException(errorMessage, e); - } - } -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/CoverageViaHttpStrategy.java b/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/CoverageViaHttpStrategy.java deleted file mode 100644 index 840e40128..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/CoverageViaHttpStrategy.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.teamscale.jacoco.agent.testimpact; - -import com.teamscale.jacoco.agent.JacocoRuntimeController; -import com.teamscale.jacoco.agent.options.AgentOptions; -import com.teamscale.jacoco.agent.logging.LoggingUtils; -import com.teamscale.report.jacoco.dump.Dump; -import com.teamscale.report.testwise.jacoco.JaCoCoTestwiseReportGenerator; -import com.teamscale.report.testwise.jacoco.cache.CoverageGenerationException; -import com.teamscale.report.testwise.model.TestExecution; -import com.teamscale.report.testwise.model.TestInfo; -import com.teamscale.report.testwise.model.builder.TestInfoBuilder; -import org.slf4j.Logger; - -import java.util.Objects; - -/** - * Strategy which directly converts the collected coverage into a JSON object in place and returns the result to the - * caller as response to the http request. If a test execution is given it is merged into the representation and - * returned together with the coverage. - */ -public class CoverageViaHttpStrategy extends TestEventHandlerStrategyBase { - - private final Logger logger = LoggingUtils.getLogger(this); - - private final JaCoCoTestwiseReportGenerator reportGenerator; - - public CoverageViaHttpStrategy(JacocoRuntimeController controller, AgentOptions agentOptions, - JaCoCoTestwiseReportGenerator reportGenerator) { - super(agentOptions, controller); - this.reportGenerator = reportGenerator; - } - - @Override - public TestInfo testEnd(String test, TestExecution testExecution) - throws JacocoRuntimeController.DumpException, CoverageGenerationException { - super.testEnd(test, testExecution); - - TestInfoBuilder builder = new TestInfoBuilder(test); - Dump dump = controller.dumpAndReset(); - reportGenerator.updateClassDirCache(); - builder.setCoverage(Objects.requireNonNull(reportGenerator.convert(dump))); - if (testExecution != null) { - builder.setExecution(testExecution); - } - TestInfo testInfo = builder.build(); - logger.debug("Generated test info {}", testInfo); - return testInfo; - } -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/TestEventHandlerStrategyBase.java b/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/TestEventHandlerStrategyBase.java deleted file mode 100644 index 0bd0aa1c4..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/TestEventHandlerStrategyBase.java +++ /dev/null @@ -1,162 +0,0 @@ -package com.teamscale.jacoco.agent.testimpact; - -import com.teamscale.client.ClusteredTestDetails; -import com.teamscale.client.HttpUtils; -import com.teamscale.client.PrioritizableTestCluster; -import com.teamscale.client.TeamscaleClient; -import com.teamscale.client.TestWithClusterId; -import com.teamscale.jacoco.agent.JacocoRuntimeController; -import com.teamscale.jacoco.agent.logging.LoggingUtils; -import com.teamscale.jacoco.agent.options.AgentOptions; -import com.teamscale.jacoco.agent.upload.teamscale.TeamscaleConfig; -import com.teamscale.report.testwise.jacoco.cache.CoverageGenerationException; -import com.teamscale.report.testwise.model.TestExecution; -import com.teamscale.report.testwise.model.TestInfo; -import org.slf4j.Logger; -import retrofit2.Response; - -import java.io.IOException; -import java.util.Collections; -import java.util.List; -import java.util.stream.Collectors; - -/** Base class for strategies to handle test events. */ -public abstract class TestEventHandlerStrategyBase { - - private final Logger logger = LoggingUtils.getLogger(this); - - /** Controls the JaCoCo runtime. */ - protected final JacocoRuntimeController controller; - - /** The timestamp at which the /test/start endpoint has been called last time. */ - private long startTimestamp = -1; - - /** The options the user has configured for the agent. */ - protected final AgentOptions agentOptions; - - /** May be null if the user did not configure Teamscale. */ - protected final TeamscaleClient teamscaleClient; - - - protected TestEventHandlerStrategyBase(AgentOptions agentOptions, JacocoRuntimeController controller) { - this.controller = controller; - this.agentOptions = agentOptions; - this.teamscaleClient = agentOptions.createTeamscaleClient(true); - } - - /** Called when test test with the given name is about to start. */ - public void testStart(String test) { - logger.debug("Test {} started", test); - // Reset coverage so that we only record coverage that belongs to this particular test case. - controller.reset(); - controller.setSessionId(test); - startTimestamp = System.currentTimeMillis(); - } - - /** - * Called when the test with the given name finished. - * - * @param test Uniform path of the test - * @param testExecution A test execution object holding the test result and error message. May be null if none is - * given in the request. - * @return The body of the response. null indicates "204 No content". Non-null results will be treated - * as a json response. - */ - public TestInfo testEnd(String test, - TestExecution testExecution) throws JacocoRuntimeController.DumpException, CoverageGenerationException { - if (testExecution != null) { - testExecution.uniformPath = test; - if (startTimestamp != -1) { - long endTimestamp = System.currentTimeMillis(); - testExecution.setDurationMillis(endTimestamp - startTimestamp); - } - } - logger.debug("Test {} ended with test execution {}", test, testExecution); - return null; - } - - /** - * Retrieves impacted tests from Teamscale, if a {@link #teamscaleClient} has been configured. - * - * @param availableTests List of all available tests that could be run or null if the user does not want to - * provide one. - * @param includeNonImpactedTests If this is true, only performs prioritization, no selection. - * @param baseline Optional baseline for the considered changes. - * @throws IOException if the request to Teamscale failed. - * @throws UnsupportedOperationException if the user did not properly configure the {@link #teamscaleClient}. - */ - public List testRunStart(List availableTests, - boolean includeNonImpactedTests, - boolean includeAddedTests, boolean includeFailedAndSkipped, - String baseline, String baselineRevision) throws IOException { - int availableTestCount = 0; - List availableTestsWithClusterId = null; - if (availableTests != null) { - availableTestCount = availableTests.size(); - availableTestsWithClusterId = availableTests.stream() - .map(availableTest -> TestWithClusterId.Companion.fromClusteredTestDetails(availableTest, getPartition())) - .collect( - Collectors.toList()); - } - logger.debug("Test run started with {} available tests. baseline = {}, includeNonImpactedTests = {}", - availableTestCount, baseline, includeNonImpactedTests); - validateConfiguration(); - - Response> response = teamscaleClient - .getImpactedTests(availableTestsWithClusterId, baseline, baselineRevision, - agentOptions.getTeamscaleServerOptions().commit, - agentOptions.getTeamscaleServerOptions().revision, - agentOptions.getTeamscaleServerOptions().repository, - Collections.singletonList(agentOptions.getTeamscaleServerOptions().partition), - includeNonImpactedTests, includeAddedTests, includeFailedAndSkipped); - if (response.isSuccessful()) { - List prioritizableTestClusters = response.body(); - logger.debug("Teamscale suggested these tests: {}", prioritizableTestClusters); - return prioritizableTestClusters; - } else { - String responseBody = HttpUtils.getErrorBodyStringSafe(response); - throw new IOException( - "Request to Teamscale to get impacted tests failed with HTTP status " + response.code() + - " " + response.message() + ". Response body: " + responseBody); - } - } - - /** - * Returns the partition defined in the agent options. Asserts that the partition is defined. - */ - private String getPartition() { - String partition = agentOptions.getTeamscaleServerOptions().partition; - if (partition == null) { - throw new UnsupportedOperationException( - "You must provide a partition via the agent's '" + TeamscaleConfig.TEAMSCALE_PARTITION_OPTION + "' option or using the /partition REST endpoint."); - } - return partition; - } - - private void validateConfiguration() { - if (teamscaleClient == null) { - throw new UnsupportedOperationException("You did not configure a connection to Teamscale in the agent." + - " Thus, you cannot use the agent to retrieve impacted tests via the testrun/start REST endpoint." + - " Please use the 'teamscale-' agent parameters to configure a Teamscale connection."); - } - if (!agentOptions.getTeamscaleServerOptions().hasCommitOrRevision()) { - throw new UnsupportedOperationException( - "You must provide a revision or commit via the agent's '" + TeamscaleConfig.TEAMSCALE_REVISION_OPTION + "', '" + - TeamscaleConfig.TEAMSCALE_REVISION_MANIFEST_JAR_OPTION + "', '" + TeamscaleConfig.TEAMSCALE_COMMIT_OPTION + - "', '" + TeamscaleConfig.TEAMSCALE_COMMIT_MANIFEST_JAR_OPTION + "' or '" + - AgentOptions.GIT_PROPERTIES_JAR_OPTION + "' option." + - " Auto-detecting the git.properties does not work since we need the commit before any code" + - " has been profiled in order to obtain the prioritized test cases from the TIA."); - } - } - - /** - * Signals that the test run has ended. Strategies that support this can upload a report via the - * {@link #teamscaleClient} here. - */ - public void testRunEnd(boolean partial) throws IOException, CoverageGenerationException { - throw new UnsupportedOperationException("You configured the agent in a mode that does not support uploading " + - "reports to Teamscale. Please configure 'tia-mode=teamscale-upload' or simply don't call" + - "POST /testrun/end."); - } -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/TestExecutionWriter.java b/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/TestExecutionWriter.java deleted file mode 100644 index e62261cda..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/TestExecutionWriter.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.teamscale.jacoco.agent.testimpact; - -import com.teamscale.client.JsonUtils; -import com.teamscale.jacoco.agent.logging.LoggingUtils; -import com.teamscale.report.testwise.model.TestExecution; -import org.slf4j.Logger; - -import java.io.File; -import java.io.IOException; -import java.io.RandomAccessFile; -import java.nio.charset.StandardCharsets; - -/** - * Helper class for writing a list of test executions to a file. This class ensures that we never have to hold all test - * executions in memory but rather incrementally append to the output file. This ensures that we don't use unnecessary - * amounts of memory during profiling. - */ -public class TestExecutionWriter { - - private final Logger logger = LoggingUtils.getLogger(this); - - private final File testExecutionFile; - private boolean hasWrittenAtLeastOneExecution = false; - - public TestExecutionWriter(File testExecutionFile) { - this.testExecutionFile = testExecutionFile; - logger.debug("Writing test executions to {}", testExecutionFile); - } - - /** Appends the given {@link TestExecution} to the test execution list file. */ - public synchronized void append(TestExecution testExecution) throws IOException { - String json = JsonUtils.serializeToJson(testExecution); - - // the file contains a JSON array if it exists and to append to it, we strip the trailing "]" and append - // our new entry and a closing "]" - // "rwd" means open for read-write and flush all changes directly to disk - try (RandomAccessFile file = new RandomAccessFile(testExecutionFile, "rwd")) { - String textToWrite = json + "]"; - if (hasWrittenAtLeastOneExecution) { - textToWrite = "," + textToWrite; - // overwrite the trailing "]" - file.seek(file.length() - 1); - } else { - textToWrite = "[" + textToWrite; - } - - file.write(textToWrite.getBytes(StandardCharsets.UTF_8)); - } - - hasWrittenAtLeastOneExecution = true; - } - -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/TestwiseCoverageAgent.java b/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/TestwiseCoverageAgent.java deleted file mode 100644 index 8effbdfb6..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/TestwiseCoverageAgent.java +++ /dev/null @@ -1,75 +0,0 @@ -/*-------------------------------------------------------------------------+ -| | -| Copyright (c) 2009-2018 CQSE GmbH | -| | -+-------------------------------------------------------------------------*/ -package com.teamscale.jacoco.agent.testimpact; - -import com.teamscale.jacoco.agent.AgentBase; -import com.teamscale.jacoco.agent.GenericExceptionMapper; -import com.teamscale.jacoco.agent.logging.LoggingUtils; -import com.teamscale.jacoco.agent.options.AgentOptions; -import com.teamscale.report.testwise.jacoco.JaCoCoTestwiseReportGenerator; -import org.glassfish.jersey.server.ResourceConfig; -import org.glassfish.jersey.server.ServerProperties; -import org.slf4j.Logger; - -import java.io.IOException; - -/** - * A wrapper around the JaCoCo Java agent that starts a HTTP server and listens for test events. - */ -public class TestwiseCoverageAgent extends AgentBase { - - /** - * The test event strategy handler. - */ - protected final TestEventHandlerStrategyBase testEventHandler; - - /** Creates a {@link TestwiseCoverageAgent} based on the given options. */ - public static TestwiseCoverageAgent create(AgentOptions agentOptions) throws IOException { - Logger logger = LoggingUtils.getLogger(JaCoCoTestwiseReportGenerator.class); - JaCoCoTestwiseReportGenerator reportGenerator = new JaCoCoTestwiseReportGenerator( - agentOptions.getClassDirectoriesOrZips(), agentOptions.getLocationIncludeFilter(), - agentOptions.getDuplicateClassFileBehavior(), LoggingUtils.wrap(logger)); - return new TestwiseCoverageAgent(agentOptions, - new TestExecutionWriter(agentOptions.createNewFileInOutputDirectory("test-execution", "json")), - reportGenerator); - } - - - public TestwiseCoverageAgent(AgentOptions options, TestExecutionWriter testExecutionWriter, - JaCoCoTestwiseReportGenerator reportGenerator) throws IllegalStateException { - super(options); - switch (options.getTestwiseCoverageMode()) { - case TEAMSCALE_UPLOAD: - testEventHandler = new CoverageToTeamscaleStrategy(controller, options, reportGenerator); - break; - case DISK: - testEventHandler = new CoverageToDiskStrategy(controller, options, reportGenerator); - break; - case HTTP: - testEventHandler = new CoverageViaHttpStrategy(controller, options, reportGenerator); - break; - default: - testEventHandler = new CoverageToExecFileStrategy(controller, options, testExecutionWriter); - break; - } - // Set to empty to not end up with a default session in case no tests are executed, - // which in turn causes a warning because we didn't write a corresponding test detail - controller.setSessionId(""); - } - - @Override - protected ResourceConfig initResourceConfig() { - ResourceConfig resourceConfig = new ResourceConfig(); - resourceConfig.property(ServerProperties.WADL_FEATURE_DISABLE, Boolean.TRUE.toString()); - TestwiseCoverageResource.setAgent(this); - return resourceConfig.register(TestwiseCoverageResource.class).register(GenericExceptionMapper.class); - } - - @Override - public void dumpReport() { - // Dumping via the API is not supported in testwise mode. Ending the test run dumps automatically - } -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/TestwiseCoverageResource.java b/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/TestwiseCoverageResource.java deleted file mode 100644 index 6630be4e4..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/TestwiseCoverageResource.java +++ /dev/null @@ -1,106 +0,0 @@ -package com.teamscale.jacoco.agent.testimpact; - -import com.teamscale.client.ClusteredTestDetails; -import com.teamscale.client.PrioritizableTestCluster; -import com.teamscale.jacoco.agent.JacocoRuntimeController; -import com.teamscale.jacoco.agent.ResourceBase; -import com.teamscale.report.testwise.jacoco.cache.CoverageGenerationException; -import com.teamscale.report.testwise.model.TestExecution; -import com.teamscale.report.testwise.model.TestInfo; - -import javax.ws.rs.DefaultValue; -import javax.ws.rs.GET; -import javax.ws.rs.POST; -import javax.ws.rs.Path; -import javax.ws.rs.PathParam; -import javax.ws.rs.Produces; -import javax.ws.rs.QueryParam; -import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.Response; -import java.io.IOException; -import java.util.List; - -/** - * The resource of the Jersey + Jetty http server holding all the endpoints specific for the - * {@link TestwiseCoverageAgent}. - */ -@Path("/") -public class TestwiseCoverageResource extends ResourceBase { - - /** Path parameter placeholder used in the HTTP requests. */ - private static final String TEST_ID_PARAMETER = "testId"; - - private static TestwiseCoverageAgent testwiseCoverageAgent; - - /** - * Static setter to inject the {@link TestwiseCoverageAgent} to the resource. - */ - public static void setAgent(TestwiseCoverageAgent agent) { - TestwiseCoverageResource.testwiseCoverageAgent = agent; - ResourceBase.agentBase = agent; - } - - /** Returns the session ID of the current test. */ - @GET - @Path("/test") - public String getTest() { - return testwiseCoverageAgent.controller.getSessionId(); - } - - - /** Handles the start of a new test case by setting the session ID. */ - @POST - @Path("/test/start/{" + TEST_ID_PARAMETER + "}") - public Response handleTestStart(@PathParam(TEST_ID_PARAMETER) String testId) { - if (testId == null || testId.isEmpty()) { - handleBadRequest("Test name is missing!"); - } - - logger.debug("Start test {}", testId); - - testwiseCoverageAgent.testEventHandler.testStart(testId); - return Response.noContent().build(); - } - - /** Handles the end of a test case by resetting the session ID. */ - @POST - @Produces(MediaType.APPLICATION_JSON) - @Path("/test/end/{" + TEST_ID_PARAMETER + "}") - public TestInfo handleTestEnd(@PathParam(TEST_ID_PARAMETER) String testId, - TestExecution testExecution) throws JacocoRuntimeController.DumpException, CoverageGenerationException { - if (testId == null || testId.isEmpty()) { - handleBadRequest("Test name is missing!"); - } - - logger.debug("End test {}", testId); - - return testwiseCoverageAgent.testEventHandler.testEnd(testId, - testExecution); - } - - /** Handles the start of a new testrun. */ - @POST - @Produces(MediaType.APPLICATION_JSON) - @Path("/testrun/start") - public List handleTestRunStart( - @QueryParam("include-non-impacted") boolean includeNonImpactedTests, - @QueryParam("include-added-tests") boolean includeAddedTests, - @QueryParam("include-failed-and-skipped") boolean includeFailedAndSkipped, - @QueryParam("baseline") String baseline, - @QueryParam("baseline-revision") String baselineRevision, - List availableTests) throws IOException { - - return testwiseCoverageAgent.testEventHandler.testRunStart(availableTests, - includeNonImpactedTests, includeAddedTests, - includeFailedAndSkipped, baseline, baselineRevision); - } - - /** Handles the end of a new testrun. */ - @POST - @Path("/testrun/end") - public Response handleTestRunEnd( - @DefaultValue("false") @QueryParam("partial") boolean partial) throws IOException, CoverageGenerationException { - testwiseCoverageAgent.testEventHandler.testRunEnd(partial); - return Response.noContent().build(); - } -} 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/azure/AzureFileStorageConfig.java b/agent/src/main/java/com/teamscale/jacoco/agent/upload/azure/AzureFileStorageConfig.java deleted file mode 100644 index 5ae61e326..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/upload/azure/AzureFileStorageConfig.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.teamscale.jacoco.agent.upload.azure; - -import com.teamscale.jacoco.agent.options.AgentOptionParseException; -import com.teamscale.jacoco.agent.options.AgentOptionsParser; -import okhttp3.HttpUrl; - -/** Config necessary to upload files to an azure file storage. */ -public class AzureFileStorageConfig { - /** The URL to the azure file storage */ - public HttpUrl url; - - /** The access key of the azure file storage */ - public String accessKey; - - /** Checks if none of the required fields is null. */ - public boolean hasAllRequiredFieldsSet() { - return url != null && accessKey != null; - } - - /** Checks if all required fields are null. */ - public boolean hasAllRequiredFieldsNull() { - return url == null && accessKey == null; - } - - /** - * Handles all command-line options prefixed with 'azure-' - * - * @return true if it has successfully processed the given option. - */ - public static boolean handleAzureFileStorageOptions(AzureFileStorageConfig azureFileStorageConfig, String key, - String value) - throws AgentOptionParseException { - switch (key) { - case "azure-url": - azureFileStorageConfig.url = AgentOptionsParser.parseUrl(key, value); - return true; - case "azure-key": - azureFileStorageConfig.accessKey = value; - return true; - default: - return false; - } - } -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/upload/azure/AzureFileStorageHttpUtils.java b/agent/src/main/java/com/teamscale/jacoco/agent/upload/azure/AzureFileStorageHttpUtils.java deleted file mode 100644 index ac0e1792e..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/upload/azure/AzureFileStorageHttpUtils.java +++ /dev/null @@ -1,133 +0,0 @@ -package com.teamscale.jacoco.agent.upload.azure; - -import com.teamscale.jacoco.agent.upload.UploaderException; -import com.teamscale.jacoco.agent.util.Assertions; - -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; -import java.io.UnsupportedEncodingException; -import java.security.InvalidKeyException; -import java.security.NoSuchAlgorithmException; -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.time.format.DateTimeFormatter; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Base64; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.stream.Collectors; - -import static com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.CONTENT_ENCODING; -import static com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.CONTENT_LANGUAGE; -import static com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.CONTENT_LENGTH; -import static com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.CONTENT_MD_5; -import static com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.CONTENT_TYPE; -import static com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.DATE; -import static com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.IF_MATCH; -import static com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.IF_MODIFIED_SINCE; -import static com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.IF_NONE_MATCH; -import static com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.IF_UNMODIFIED_SINCE; -import static com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.RANGE; -import static com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.X_MS_DATE; -import static com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.X_MS_VERSION; - -/** Utils class for communicating with an azure file storage. */ -/* package */ class AzureFileStorageHttpUtils { - - /** Version of the azure file storage. Must be in every request */ - private static final String VERSION = "2018-03-28"; - - /** Formatting pattern for every date in a request */ - private static final DateTimeFormatter FORMAT = DateTimeFormatter.ofPattern("E, dd MMM y HH:mm:ss z").withZone( - ZoneId.of("GMT")); - - - /** Creates the string that must be signed as the authorization for the request. */ - private static String createSignString(EHttpMethod httpMethod, Map headers, String account, - String path, Map queryParameters) { - Assertions.isTrue(headers.keySet().containsAll(Arrays.asList(X_MS_DATE, X_MS_VERSION)), - "Headers for the azure request cannot be empty! At least 'x-ms-version' and 'x-ms-date' must be set"); - - Map xmsHeader = headers.entrySet().stream().filter(x -> x.getKey().startsWith("x-ms")) - .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); - - return String.join("\n", httpMethod.toString(), - getStringOrEmpty(headers, CONTENT_ENCODING), - getStringOrEmpty(headers, CONTENT_LANGUAGE), - getStringOrEmpty(headers, CONTENT_LENGTH), - getStringOrEmpty(headers, CONTENT_MD_5), - getStringOrEmpty(headers, CONTENT_TYPE), - getStringOrEmpty(headers, DATE), - getStringOrEmpty(headers, IF_MODIFIED_SINCE), - getStringOrEmpty(headers, IF_MATCH), - getStringOrEmpty(headers, IF_NONE_MATCH), - getStringOrEmpty(headers, IF_UNMODIFIED_SINCE), - getStringOrEmpty(headers, RANGE), - createCanonicalizedString(xmsHeader), - createCanonicalizedResources(account, path, queryParameters)); - } - - /** Returns the value from the map for the given key or an empty string if the key does not exist. */ - private static String getStringOrEmpty(Map map, String key) { - return Objects.toString(map.get(key), ""); - } - - /** Creates the string for the canonicalized resources. */ - private static String createCanonicalizedResources(String account, String path, Map options) { - String canonicalizedResources = String.format("/%s%s", account, path); - - if (options.size() > 0) { - canonicalizedResources += "\n" + createCanonicalizedString(options); - } - - return canonicalizedResources; - } - - /** Creates a string with a map where each key-value pair is in a newline separated by a colon. */ - private static String createCanonicalizedString(Map options) { - List sortedKeys = new ArrayList<>(options.keySet()); - sortedKeys.sort(String::compareTo); - - List values = sortedKeys.stream() - .map(key -> String.format("%s:%s", key, options.get(key))).collect(Collectors.toList()); - return String.join("\n", values); - } - - /** Creates the string which is needed for the authorization of an azure file storage request. */ - /* package */ - static String getAuthorizationString(EHttpMethod method, String account, String key, String path, - Map headers, Map queryParameters) - throws UploaderException { - String stringToSign = createSignString(method, headers, account, path, queryParameters); - - try { - Mac mac = Mac.getInstance("HmacSHA256"); - mac.init(new SecretKeySpec(Base64.getDecoder().decode(key), "HmacSHA256")); - String authKey = new String(Base64.getEncoder().encode(mac.doFinal(stringToSign.getBytes("UTF-8")))); - return "SharedKey " + account + ":" + authKey; - } catch (NoSuchAlgorithmException | UnsupportedEncodingException e) { - throw new UploaderException("Something is really wrong...", e); - } catch (InvalidKeyException | IllegalArgumentException e) { - throw new UploaderException(String.format("The given access key is malformed: %s", key), e); - } - } - - /** Returns the list of headers which must be present at every request */ - /* package */ - static Map getBaseHeaders() { - Map headers = new HashMap<>(); - headers.put(X_MS_VERSION, AzureFileStorageHttpUtils.VERSION); - headers.put(X_MS_DATE, FORMAT.format(LocalDateTime.now())); - return headers; - } - - /** Simple enum for all available HTTP methods. */ - public enum EHttpMethod { - PUT, - HEAD - } -} - diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/upload/azure/AzureFileStorageUploader.java b/agent/src/main/java/com/teamscale/jacoco/agent/upload/azure/AzureFileStorageUploader.java deleted file mode 100644 index 3b7d1caed..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/upload/azure/AzureFileStorageUploader.java +++ /dev/null @@ -1,254 +0,0 @@ -package com.teamscale.jacoco.agent.upload.azure; - -import com.teamscale.client.EReportFormat; -import com.teamscale.client.FileSystemUtils; -import com.teamscale.jacoco.agent.upload.HttpZipUploaderBase; -import com.teamscale.jacoco.agent.upload.IUploadRetry; -import com.teamscale.jacoco.agent.upload.UploaderException; -import com.teamscale.report.jacoco.CoverageFile; -import okhttp3.MediaType; -import okhttp3.RequestBody; -import okhttp3.ResponseBody; -import retrofit2.Response; - -import java.io.File; -import java.io.IOException; -import java.nio.file.Path; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Properties; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -import static com.teamscale.jacoco.agent.upload.azure.AzureFileStorageHttpUtils.EHttpMethod.HEAD; -import static com.teamscale.jacoco.agent.upload.azure.AzureFileStorageHttpUtils.EHttpMethod.PUT; -import static com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.AUTHORIZATION; -import static com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.CONTENT_LENGTH; -import static com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.CONTENT_TYPE; -import static com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.X_MS_CONTENT_LENGTH; -import static com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.X_MS_RANGE; -import static com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.X_MS_TYPE; -import static com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.X_MS_WRITE; -import static com.teamscale.jacoco.agent.upload.teamscale.TeamscaleUploader.RETRY_UPLOAD_FILE_SUFFIX; - -/** Uploads the coverage archive to a provided azure file storage. */ -public class AzureFileStorageUploader extends HttpZipUploaderBase implements IUploadRetry { - - /** Pattern matches the host of a azure file storage */ - private static final Pattern AZURE_FILE_STORAGE_HOST_PATTERN = Pattern - .compile("^(\\w*)\\.file\\.core\\.windows\\.net$"); - - /** The access key for the azure file storage */ - private final String accessKey; - - /** The account for the azure file storage */ - private final String account; - - /** Constructor. */ - public AzureFileStorageUploader(AzureFileStorageConfig config, List additionalMetaDataFiles) - throws UploaderException { - super(config.url, additionalMetaDataFiles, IAzureUploadApi.class); - this.accessKey = config.accessKey; - this.account = getAccount(); - - validateUploadUrl(); - } - - @Override - public void markFileForUploadRetry(CoverageFile coverageFile) { - File uploadMetadataFile = new File(FileSystemUtils.replaceFilePathFilenameWith( - FileSystemUtils.normalizeSeparators(coverageFile.toString()), - coverageFile.getName() + RETRY_UPLOAD_FILE_SUFFIX)); - try { - uploadMetadataFile.createNewFile(); - } 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 properties) { - // The azure uploader does not have any special reupload properties, so it will - // just use the normal upload instead. - this.upload(coverageFile); - } - - /** - * Extracts and returns the account of the provided azure file storage from the URL. - */ - private String getAccount() throws UploaderException { - Matcher matcher = AZURE_FILE_STORAGE_HOST_PATTERN.matcher(this.uploadUrl.host()); - if (matcher.matches()) { - return matcher.group(1); - } else { - throw new UploaderException(String.format("URL is malformed. Must be in the format " - + "\"https://.file.core.windows.net//\", but was instead: %s", uploadUrl)); - } - } - - @Override - public String describe() { - return String.format("Uploading coverage to the Azure File Storage at %s", this.uploadUrl); - } - - @Override - protected Response uploadCoverageZip(File zipFile) throws IOException, UploaderException { - String fileName = createFileName(); - if (checkFile(fileName).isSuccessful()) { - logger.warn(String.format("The file %s does already exists at %s", fileName, uploadUrl)); - } - - return createAndFillFile(zipFile, fileName); - } - - /** - * Makes sure that the upload url is valid and that it exists on the file storage. If some directories do not - * exists, they will be created. - */ - private void validateUploadUrl() throws UploaderException { - List pathParts = this.uploadUrl.pathSegments(); - - if (pathParts.size() < 2) { - throw new UploaderException(String.format( - "%s is too short for a file path on the storage. " - + "At least the share must be provided: https://.file.core.windows.net//", - uploadUrl.url().getPath())); - } - - try { - checkAndCreatePath(pathParts); - } catch (IOException e) { - throw new UploaderException(String.format( - "Checking the validity of %s failed. " - + "There is probably something wrong with the URL or a problem with the account/key: ", - this.uploadUrl.url().getPath()), e); - } - } - - /** - * Checks the directory path in the azure url. Creates any missing directories. - */ - private void checkAndCreatePath(List pathParts) throws IOException, UploaderException { - for (int i = 2; i <= pathParts.size() - 1; i++) { - String directoryPath = String.format("/%s/", String.join("/", pathParts.subList(0, i))); - if (!checkDirectory(directoryPath).isSuccessful()) { - Response mkdirResponse = createDirectory(directoryPath); - if (!mkdirResponse.isSuccessful()) { - throw new UploaderException(String.format("Creation of path '/%s' was unsuccessful", directoryPath), - mkdirResponse); - } - } - } - } - - /** Creates a file name for the zip-archive containing the coverage. */ - private String createFileName() { - return String.format("%s-%s.zip", EReportFormat.JACOCO.name().toLowerCase(), System.currentTimeMillis()); - } - - /** Checks if the file with the given name exists */ - private Response checkFile(String fileName) throws IOException, UploaderException { - String filePath = uploadUrl.url().getPath() + fileName; - - Map headers = AzureFileStorageHttpUtils.getBaseHeaders(); - Map queryParameters = new HashMap<>(); - - String auth = AzureFileStorageHttpUtils.getAuthorizationString(HEAD, account, accessKey, filePath, headers, - queryParameters); - - headers.put(AUTHORIZATION, auth); - return getApi().head(filePath, headers, queryParameters).execute(); - } - - /** Checks if the directory given by the specified path does exist. */ - private Response checkDirectory(String directoryPath) throws IOException, UploaderException { - Map headers = AzureFileStorageHttpUtils.getBaseHeaders(); - - Map queryParameters = new HashMap<>(); - queryParameters.put("restype", "directory"); - - String auth = AzureFileStorageHttpUtils.getAuthorizationString(HEAD, account, accessKey, directoryPath, headers, - queryParameters); - - headers.put(AUTHORIZATION, auth); - return getApi().head(directoryPath, headers, queryParameters).execute(); - } - - /** - * Creates the directory specified by the given path. The path must contain the share where it should be created - * on. - */ - private Response createDirectory(String directoryPath) throws IOException, UploaderException { - Map headers = AzureFileStorageHttpUtils.getBaseHeaders(); - - Map queryParameters = new HashMap<>(); - queryParameters.put("restype", "directory"); - - String auth = AzureFileStorageHttpUtils.getAuthorizationString(PUT, account, accessKey, directoryPath, headers, - queryParameters); - - headers.put(AUTHORIZATION, auth); - return getApi().put(directoryPath, headers, queryParameters).execute(); - } - - /** Creates and fills a file with the given data and name. */ - private Response createAndFillFile(File zipFile, String fileName) - throws UploaderException, IOException { - Response response = createFile(zipFile, fileName); - if (response.isSuccessful()) { - return fillFile(zipFile, fileName); - } - logger.error(String.format("Creation of file '%s' was unsuccessful.", fileName)); - return response; - } - - /** - * Creates an empty file with the given name. The size is defined by the length of the given byte array. - */ - private Response createFile(File zipFile, String fileName) throws IOException, UploaderException { - String filePath = uploadUrl.url().getPath() + fileName; - - Map headers = AzureFileStorageHttpUtils.getBaseHeaders(); - headers.put(X_MS_CONTENT_LENGTH, String.valueOf(zipFile.length())); - headers.put(X_MS_TYPE, "file"); - - Map queryParameters = new HashMap<>(); - - String auth = AzureFileStorageHttpUtils.getAuthorizationString(PUT, account, accessKey, filePath, headers, - queryParameters); - - headers.put(AUTHORIZATION, auth); - return getApi().put(filePath, headers, queryParameters).execute(); - } - - /** - * Fills the file defined by the name with the given data. Should be used with {@link #createFile(File, String)}, - * because the request only writes exactly the length of the given data, so the file should be exactly as big as the - * data, otherwise it will be partially filled or is not big enough. - */ - private Response fillFile(File zipFile, String fileName) throws IOException, UploaderException { - String filePath = uploadUrl.url().getPath() + fileName; - - String range = "bytes=0-" + (zipFile.length() - 1); - String contentType = "application/octet-stream"; - - Map headers = AzureFileStorageHttpUtils.getBaseHeaders(); - headers.put(X_MS_WRITE, "update"); - headers.put(X_MS_RANGE, range); - headers.put(CONTENT_LENGTH, String.valueOf(zipFile.length())); - headers.put(CONTENT_TYPE, contentType); - - Map queryParameters = new HashMap<>(); - queryParameters.put("comp", "range"); - - String auth = AzureFileStorageHttpUtils.getAuthorizationString(PUT, account, accessKey, filePath, headers, - queryParameters); - headers.put(AUTHORIZATION, auth); - RequestBody content = RequestBody.create(MediaType.parse(contentType), zipFile); - return getApi().putData(filePath, headers, queryParameters, content).execute(); - } -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/upload/azure/AzureHttpHeader.java b/agent/src/main/java/com/teamscale/jacoco/agent/upload/azure/AzureHttpHeader.java deleted file mode 100644 index 87ae52dca..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/upload/azure/AzureHttpHeader.java +++ /dev/null @@ -1,70 +0,0 @@ -package com.teamscale.jacoco.agent.upload.azure; - -/** Constants for the names of HTTP header used in a request to an Azure file storage. */ -/* package */ class AzureHttpHeader { - /** Same as {@link #CONTENT_LENGTH} */ - /* package */ static final String X_MS_CONTENT_LENGTH = "x-ms-content-length"; - - /** Same as {@link #DATE} */ - /* package */ static final String X_MS_DATE = "x-ms-date"; - - /** Same as {@link #RANGE} */ - /* package */ static final String X_MS_RANGE = "x-ms-range"; - - /** Type of filesystem object which the request is referring to. Can be 'file' or 'directory'. */ - /* package */ static final String X_MS_TYPE = "x-ms-type"; - - /** Version of the Azure file storage API */ - /* package */ static final String X_MS_VERSION = "x-ms-version"; - - /** - * Defines the type of write operation on a file. Can either be 'Update' or 'Clear'. For 'Update' the 'Range' and - * 'Content-Length' headers must match, for 'Clear', 'Content-Length' must be set to 0. - */ - /* package */ static final String X_MS_WRITE = "x-ms-write"; - - /** - * Defines the authorization and must contain the account name and signature. Must be given in the following format: - * Authorization="[SharedKey|SharedKeyLite] :" - */ - /* package */ static final String AUTHORIZATION = "Authorization"; - - /** Content-Encoding */ - /* package */ static final String CONTENT_ENCODING = "Content-Encoding"; - - /** Content-Language */ - /* package */ static final String CONTENT_LANGUAGE = "Content-Language"; - - /** Content-Length */ - /* package */ static final String CONTENT_LENGTH = "Content-Length"; - - /** The md5 hash of the sent content. */ - /* package */ static final String CONTENT_MD_5 = "Content-MD5"; - - /** Content-Type */ - /* package */ static final String CONTENT_TYPE = "Content-Type"; - - /** The date time of the request */ - /* package */ static final String DATE = "Date"; - - /** Only send the response if the entity has not been modified since a specific time. */ - /* package */ static final String IF_UNMODIFIED_SINCE = "If-Unmodified-Since"; - - /** Allows a 304 Not Modified to be returned if content is unchanged. */ - /* package */ static final String IF_MODIFIED_SINCE = "If-Modified-Since"; - - /** - * Only perform the action if the client supplied entity matches the same entity on the server. This is mainly for - * methods like PUT to only update a resource if it has not been modified since the user last updated it. - */ - /* package */ static final String IF_MATCH = "If-Match"; - - /** Allows a 304 Not Modified to be returned if content is unchanged */ - /* package */ static final String IF_NONE_MATCH = "If-None-Match"; - - /** - * Specifies the range of bytes to be written. Both the start and end of the range must be specified. Must be given - * in the following format: "bytes=startByte-endByte" - */ - /* package */ static final String RANGE = "Range"; -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/upload/azure/IAzureUploadApi.java b/agent/src/main/java/com/teamscale/jacoco/agent/upload/azure/IAzureUploadApi.java deleted file mode 100644 index de56755cf..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/upload/azure/IAzureUploadApi.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.teamscale.jacoco.agent.upload.azure; - -import okhttp3.RequestBody; -import okhttp3.ResponseBody; -import retrofit2.Call; -import retrofit2.Retrofit; -import retrofit2.http.Body; -import retrofit2.http.HEAD; -import retrofit2.http.HeaderMap; -import retrofit2.http.PUT; -import retrofit2.http.Path; -import retrofit2.http.QueryMap; - -import java.util.Map; - -/** {@link Retrofit} API specification for the {@link AzureFileStorageUploader}. */ -public interface IAzureUploadApi { - - /** PUT call to the azure file storage without any data in the body */ - @PUT("{path}") - public Call put( - @Path(value = "path", encoded = true) String path, - @HeaderMap Map headers, - @QueryMap Map query - ); - - /** PUT call to the azure file storage with data in the body */ - @PUT("{path}") - public Call putData( - @Path(value = "path", encoded = true) String path, - @HeaderMap Map headers, - @QueryMap Map query, - @Body RequestBody content - ); - - /** HEAD call to the azure file storage */ - @HEAD("{path}") - public Call head( - @Path(value = "path", encoded = true) String path, - @HeaderMap Map headers, - @QueryMap Map query - ); -} 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/TeamscaleConfig.java b/agent/src/main/java/com/teamscale/jacoco/agent/upload/teamscale/TeamscaleConfig.java deleted file mode 100644 index b3efb279c..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/upload/teamscale/TeamscaleConfig.java +++ /dev/null @@ -1,162 +0,0 @@ -package com.teamscale.jacoco.agent.upload.teamscale; - -import com.teamscale.client.CommitDescriptor; -import com.teamscale.client.StringUtils; -import com.teamscale.client.TeamscaleServer; -import com.teamscale.jacoco.agent.options.AgentOptionParseException; -import com.teamscale.jacoco.agent.options.AgentOptionsParser; -import com.teamscale.jacoco.agent.options.FilePatternResolver; -import com.teamscale.report.util.BashFileSkippingInputStream; -import com.teamscale.report.util.ILogger; - -import java.io.File; -import java.io.IOException; -import java.nio.file.Files; -import java.util.jar.JarInputStream; -import java.util.jar.Manifest; - -import static com.teamscale.jacoco.agent.options.AgentOptionsParser.parsePath; - -/** Config necessary for direct Teamscale upload. */ -public class TeamscaleConfig { - - /** Option name that allows to specify to which branch coverage should be uploaded to (branch:timestamp). */ - public static final String TEAMSCALE_COMMIT_OPTION = "teamscale-commit"; - - /** Option name that allows to specify a git commit hash to which coverage should be uploaded to. */ - public static final String TEAMSCALE_REVISION_OPTION = "teamscale-revision"; - - /** Option name that allows to specify a jar file that contains the git commit hash in a MANIFEST.MF file. */ - public static final String TEAMSCALE_REVISION_MANIFEST_JAR_OPTION = "teamscale-revision-manifest-jar"; - - /** Option name that allows to specify a jar file that contains the branch name and timestamp in a MANIFEST.MF file. */ - public static final String TEAMSCALE_COMMIT_MANIFEST_JAR_OPTION = "teamscale-commit-manifest-jar"; - - /** Option name that allows to specify a partition to which coverage should be uploaded to. */ - public static final String TEAMSCALE_PARTITION_OPTION = "teamscale-partition"; - - private final ILogger logger; - private final FilePatternResolver filePatternResolver; - - public TeamscaleConfig(ILogger logger, FilePatternResolver filePatternResolver) { - this.logger = logger; - this.filePatternResolver = filePatternResolver; - } - - /** - * Handles all command line options prefixed with "teamscale-". - * - * @return true if it has successfully processed the given option. - */ - public boolean handleTeamscaleOptions(TeamscaleServer teamscaleServer, - String key, String value) - throws AgentOptionParseException { - switch (key) { - case "teamscale-server-url": - teamscaleServer.url = AgentOptionsParser.parseUrl(key, value); - return true; - case "teamscale-project": - teamscaleServer.project = value; - return true; - case "teamscale-user": - teamscaleServer.userName = value; - return true; - case "teamscale-access-token": - teamscaleServer.userAccessToken = value; - return true; - case TEAMSCALE_PARTITION_OPTION: - teamscaleServer.partition = value; - return true; - case TEAMSCALE_COMMIT_OPTION: - teamscaleServer.commit = parseCommit(value); - return true; - case TEAMSCALE_COMMIT_MANIFEST_JAR_OPTION: - teamscaleServer.commit = getCommitFromManifest( - parsePath(filePatternResolver, key, value).toFile()); - return true; - case "teamscale-message": - teamscaleServer.setMessage(value); - return true; - case TEAMSCALE_REVISION_OPTION: - teamscaleServer.revision = value; - return true; - case "teamscale-repository": - teamscaleServer.repository = value; - return true; - case TEAMSCALE_REVISION_MANIFEST_JAR_OPTION: - teamscaleServer.revision = getRevisionFromManifest( - parsePath(filePatternResolver, key, value).toFile()); - return true; - default: - return false; - } - } - - /** - * Parses the the string representation of a commit to a {@link CommitDescriptor} object. - *

- * The expected format is "branch:timestamp". - */ - private CommitDescriptor parseCommit(String commit) throws AgentOptionParseException { - String[] split = commit.split(":"); - if (split.length != 2) { - throw new AgentOptionParseException("Invalid commit given " + commit); - } - return new CommitDescriptor(split[0], split[1]); - } - - /** - * Reads `Branch` and `Timestamp` entries from the given jar/war file's manifest and builds a commit descriptor out - * of it. - */ - private CommitDescriptor getCommitFromManifest(File jarFile) throws AgentOptionParseException { - Manifest manifest = getManifestFromJarFile(jarFile); - String branch = manifest.getMainAttributes().getValue("Branch"); - String timestamp = manifest.getMainAttributes().getValue("Timestamp"); - if (StringUtils.isEmpty(branch)) { - throw new AgentOptionParseException("No entry 'Branch' in MANIFEST"); - } else if (StringUtils.isEmpty(timestamp)) { - throw new AgentOptionParseException("No entry 'Timestamp' in MANIFEST"); - } - logger.debug("Found commit " + branch + ":" + timestamp + " in file " + jarFile); - return new CommitDescriptor(branch, timestamp); - } - - /** - * Reads `Git_Commit` entry from the given jar/war file's manifest and sets it as revision. - */ - private String getRevisionFromManifest(File jarFile) throws AgentOptionParseException { - Manifest manifest = getManifestFromJarFile(jarFile); - String revision = manifest.getMainAttributes().getValue("Revision"); - if (StringUtils.isEmpty(revision)) { - // currently needed option for a customer - if (manifest.getAttributes("Git") != null) { - revision = manifest.getAttributes("Git").getValue("Git_Commit"); - } - - if (StringUtils.isEmpty(revision)) { - throw new AgentOptionParseException("No entry 'Revision' in MANIFEST"); - } - } - logger.debug("Found revision " + revision + " in file " + jarFile); - return revision; - } - - /** - * Reads the JarFile to extract the MANIFEST.MF. - */ - private Manifest getManifestFromJarFile(File jarFile) throws AgentOptionParseException { - try (JarInputStream jarStream = new JarInputStream( - new BashFileSkippingInputStream(Files.newInputStream(jarFile.toPath())))) { - Manifest manifest = jarStream.getManifest(); - if (manifest == null) { - throw new AgentOptionParseException( - "Unable to read manifest from " + jarFile + ". Maybe the manifest is corrupt?"); - } - return manifest; - } catch (IOException e) { - throw new AgentOptionParseException("Reading jar " + jarFile.getAbsolutePath() + " for obtaining commit " - + "descriptor from MANIFEST failed", e); - } - } -} 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/Benchmark.java b/agent/src/main/java/com/teamscale/jacoco/agent/util/Benchmark.java deleted file mode 100644 index 64f95652b..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/util/Benchmark.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.teamscale.jacoco.agent.util; - -import com.teamscale.jacoco.agent.logging.LoggingUtils; -import org.slf4j.Logger; - -/** - * Measures how long a certain piece of code takes and logs it to the debug log. - *

- * Use this in a try-with-resources. Time measurement starts when the resource - * is created and ends when it is closed. - */ -public class Benchmark implements AutoCloseable { - - /** The logger. */ - private final Logger logger = LoggingUtils.getLogger(this); - - /** The time when the resource was created. */ - private final long startTime; - - /** The description to use in the log message. */ - private String description; - - /** Constructor. */ - public Benchmark(String description) { - this.description = description; - startTime = System.nanoTime(); - } - - /** {@inheritDoc} */ - @Override - public void close() { - long endTime = System.nanoTime(); - logger.debug("{} took {}s", description, (endTime - startTime) / 1_000_000_000L); - } -} 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..cd7ac0fdf --- /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.classDirectoriesOrZips, + options.locationIncludeFilter, + options.duplicateClassFileBehavior, + options.ignoreUncoveredClasses, + 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.teamscaleServer.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 ?: return + 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..3cfaf93f3 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/AgentBase.kt @@ -0,0 +1,153 @@ +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(): String = + if (options.obfuscateSecurityRelatedOutputs) { + options.obfuscatedOptionsString + } else { + options.originalOptionsString + } ?: "" + } + } + + 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.httpServerPort?.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() { + val port = options.httpServerPort + require(port != null) { "Port must be set." } + + logger.info("Listening for test events on port {}.", port) + + // 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 = port + 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.httpServerPort?.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..c4cbd4ffa --- /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.validateSsl) + + 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.loggingConfig) + logger.info("Logging to ${LogDirectoryPropertyDefiner().getPropertyValue()}") + } + + if (agentOptions.teamscaleServer.isConfiguredForServerConnection) { + if (LogToTeamscaleAppender.addTeamscaleAppenderTo(LoggingUtils.loggerContext, agentOptions)) { + logger.info("Logs are being forwarded to Teamscale at ${agentOptions.teamscaleServer.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.debugLogDirectory) + 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..5107dbed4 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/ResourceBase.kt @@ -0,0 +1,139 @@ +package com.teamscale.jacoco.agent + +import com.teamscale.client.CommitDescriptor +import com.teamscale.client.StringUtils +import com.teamscale.jacoco.agent.logging.LoggingUtils +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 + +/** + * 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.teamscaleServer.partition.orEmpty() + + @get:Path("/message") + @get:GET + val message: String + /** Returns the upload message for the Teamscale upload. */ + get() = agentBase.options.teamscaleServer.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.teamscaleServer.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.teamscaleServer.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.teamscaleServer.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.teamscaleServer.commit = CommitDescriptor.parse(commit) + + return Response.noContent().build() + } + + private val revisionInfo: RevisionInfo + /** Returns revision information for the Teamscale upload. */ + get() { + val server = agentBase.options.teamscaleServer + 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..2cf73bb15 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commandline/Validator.kt @@ -0,0 +1,56 @@ +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..a93df303c --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitPropertiesLocatingTransformer.kt @@ -0,0 +1,78 @@ +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) { + 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..5af6f29ff --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitPropertiesLocatorUtils.kt @@ -0,0 +1,508 @@ +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.URI +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 = URI.create(artefactUrl).toURL().openConnection().getContent() + // obtain the physical location of the class file. It is created on demand in /standalone/tmp/vfs + val getPhysicalFileMethod = virtualFile.javaClass.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 + ): 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${File.separator}${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) + + 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 (revision.isNullOrEmpty()) { + 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 (teamscaleCommitBranch.isNullOrEmpty() || teamscaleCommitTime.isNullOrEmpty()) { + 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 (!gitBranch.isNullOrEmpty() && !gitTime.isNullOrEmpty()) { + 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..708b33db9 --- /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? + ): List + } +} 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..9f5d02bb1 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/sapnwdi/NwdiMarkerClassLocatingTransformer.kt @@ -0,0 +1,80 @@ +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: List +) : ClassFileTransformer { + private val logger = getLogger(this) + private val markerClassesToApplications = + apps.associateBy { it.markerClass.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 + } + + // only kick off search if the marker class was found. + val application = markerClassesToApplications[className] ?: 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, application) + } + } 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..cb828c14e --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/configuration/ConfigurationViaTeamscale.kt @@ -0,0 +1,163 @@ +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) { + @Suppress("DEPRECATION") + 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..b5fedefdd --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/convert/Converter.kt @@ -0,0 +1,96 @@ +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.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 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..5e3f4c0cd --- /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/AgentOptionParseException.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/AgentOptionParseException.kt new file mode 100644 index 000000000..c3839b398 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/AgentOptionParseException.kt @@ -0,0 +1,10 @@ +package com.teamscale.jacoco.agent.options + +/** + * Thrown if option parsing fails. + */ +class AgentOptionParseException : Exception { + constructor(message: String?) : super(message) + constructor(e: Exception) : super(e.message, e) + 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/options/AgentOptions.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/AgentOptions.kt new file mode 100644 index 000000000..a25f081b2 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/AgentOptions.kt @@ -0,0 +1,712 @@ +package com.teamscale.jacoco.agent.options + +import com.teamscale.client.* +import com.teamscale.client.FileSystemUtils.ensureDirectoryExists +import com.teamscale.client.FileSystemUtils.getFileExtension +import com.teamscale.client.StringUtils.isEmpty +import com.teamscale.jacoco.agent.commandline.Validator +import com.teamscale.jacoco.agent.commit_resolution.git_properties.CommitInfo +import com.teamscale.jacoco.agent.commit_resolution.git_properties.GitMultiProjectPropertiesLocator +import com.teamscale.jacoco.agent.commit_resolution.git_properties.GitPropertiesLocatingTransformer +import com.teamscale.jacoco.agent.commit_resolution.git_properties.GitPropertiesLocatorUtils.getCommitInfoFromGitProperties +import com.teamscale.jacoco.agent.commit_resolution.git_properties.GitPropertiesLocatorUtils.getProjectRevisionsFromGitProperties +import com.teamscale.jacoco.agent.commit_resolution.git_properties.GitSingleProjectPropertiesLocator +import com.teamscale.jacoco.agent.commit_resolution.git_properties.GitSingleProjectPropertiesLocator.DataExtractor +import com.teamscale.jacoco.agent.commit_resolution.sapnwdi.NwdiMarkerClassLocatingTransformer +import com.teamscale.jacoco.agent.configuration.ConfigurationViaTeamscale +import com.teamscale.jacoco.agent.options.AgentOptions.Companion.GIT_PROPERTIES_COMMIT_DATE_FORMAT_OPTION +import com.teamscale.jacoco.agent.options.AgentOptions.Companion.GIT_PROPERTIES_JAR_OPTION +import com.teamscale.jacoco.agent.options.sapnwdi.DelayedSapNwdiMultiUploader +import com.teamscale.jacoco.agent.options.sapnwdi.SapNwdiApplication +import com.teamscale.jacoco.agent.upload.IUploader +import com.teamscale.jacoco.agent.upload.LocalDiskUploader +import com.teamscale.jacoco.agent.upload.UploaderException +import com.teamscale.jacoco.agent.upload.artifactory.ArtifactoryConfig +import com.teamscale.jacoco.agent.upload.artifactory.ArtifactoryUploader +import com.teamscale.jacoco.agent.upload.azure.AzureFileStorageConfig +import com.teamscale.jacoco.agent.upload.azure.AzureFileStorageUploader +import com.teamscale.jacoco.agent.upload.delay.DelayedUploader +import com.teamscale.jacoco.agent.upload.teamscale.DelayedTeamscaleMultiProjectUploader +import com.teamscale.jacoco.agent.upload.teamscale.TeamscaleConfig +import com.teamscale.jacoco.agent.upload.teamscale.TeamscaleUploader +import com.teamscale.jacoco.agent.util.AgentUtils +import com.teamscale.jacoco.agent.util.AgentUtils.mainTempDirectory +import com.teamscale.report.EDuplicateClassFileBehavior +import com.teamscale.report.util.ClasspathWildcardIncludeFilter +import com.teamscale.report.util.ILogger +import java.io.File +import java.io.IOException +import java.lang.instrument.Instrumentation +import java.nio.file.Path +import java.nio.file.Paths +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.util.* +import java.util.function.BiFunction +import java.util.function.Function +import java.util.regex.Pattern +import kotlin.io.path.exists +import kotlin.io.path.isReadable +import kotlin.io.path.isRegularFile +import kotlin.math.max + +/** + * Parses agent command line options. + */ +open class AgentOptions(private val logger: ILogger) { + /** See [GIT_PROPERTIES_JAR_OPTION] */ + @JvmField + var gitPropertiesJar: File? = null + + /** + * Related to [GIT_PROPERTIES_COMMIT_DATE_FORMAT_OPTION] + */ + @JvmField + var gitPropertiesCommitTimeFormat: DateTimeFormatter? = null + + /** + * The original options passed to the agent. + */ + @JvmField + var originalOptionsString: String? = null + + /** Whether debug logging is active or not. */ + @JvmField + var isDebugLogging: Boolean = false + + /** Explicitly defined log file. */ + @JvmField + var debugLogDirectory: Path? = null + + /** + * The directories and/or zips that contain all class files being profiled. Never null. If this is empty, classes + * should be dumped to a temporary directory which should be used as the class-dir. + */ + @JvmField + var classDirectoriesOrZips = mutableListOf() + + /** + * The logging configuration file. + */ + @JvmField + var loggingConfig: Path? = null + + /** + * The directory to write the XML traces to. + */ + var outputDirectory: Path? = null + + /** Contains the options related to teamscale-specific proxy settings for http. */ + var teamscaleProxyOptionsForHttp: TeamscaleProxyOptions? + + /** Contains the options related to teamscale-specific proxy settings for https. */ + var teamscaleProxyOptionsForHttps: TeamscaleProxyOptions? + + /** Additional metadata files to upload together with the coverage XML. */ + @JvmField + var additionalMetaDataFiles = listOf() + + /** Whether the agent should be run in testwise coverage mode or normal mode. */ + @JvmField + var mode = EMode.NORMAL + + /** The interval in minutes for dumping XML data. */ + @JvmField + var dumpIntervalInMinutes = 480 + + /** Whether to dump coverage when the JVM shuts down. */ + @JvmField + var shouldDumpOnExit = true + + /** + * Whether to search directories and jar files recursively for git.properties files + */ + @JvmField + var searchGitPropertiesRecursively = true + + /** + * Whether to validate SSL certificates, defaults to true. + */ + @JvmField + var validateSsl = true + + /** + * Whether to ignore duplicate, non-identical class files. + */ + @JvmField + var duplicateClassFileBehavior = EDuplicateClassFileBehavior.WARN + + /** + * Include patterns for fully qualified class names to pass on to JaCoCo. See [org.jacoco.core.runtime.WildcardMatcher] for the + * pattern syntax. Individual patterns must be separated by ":". + */ + @JvmField + var jacocoIncludes: String? = null + + /** + * Exclude patterns for fully qualified class names to pass on to JaCoCo. See [org.jacoco.core.runtime.WildcardMatcher] for the + * pattern syntax. Individual patterns must be separated by ":". + */ + @JvmField + var jacocoExcludes = DEFAULT_EXCLUDES + + /** + * Additional user-provided options to pass to JaCoCo. + */ + @JvmField + var additionalJacocoOptions = mutableListOf>() + + /** + * The teamscale server to which coverage should be uploaded. + */ + @JvmField + var teamscaleServer = TeamscaleServer() + + /** + * How testwise coverage should be handled in test-wise mode. + */ + @JvmField + var testwiseCoverageMode = ETestwiseCoverageMode.EXEC_FILE + + /** + * Returns the port at which the http server should listen for test execution information or null if disabled. + */ + @JvmField + var httpServerPort: Int? = null + + /** + * Whether classes without coverage should be skipped from the XML report. + */ + @JvmField + var ignoreUncoveredClasses = false + + /** + * The configuration necessary to upload files to an azure file storage + */ + @JvmField + var artifactoryConfig = ArtifactoryConfig() + + /** + * The configuration necessary to upload files to an azure file storage + */ + @JvmField + var azureFileStorageConfig = AzureFileStorageConfig() + + /** + * The configuration necessary when used in an SAP NetWeaver Java environment. + */ + @JvmField + var sapNetWeaverJavaApplications = listOf() + + /** + * Whether to obfuscate security related configuration options when dumping them into the log or onto the console or + * not. + */ + @JvmField + var obfuscateSecurityRelatedOutputs = true + + /** + * Helper class that holds the process information, Teamscale client and profiler configuration and allows to + * continuously update the profiler's info in Teamscale in the background via + * [ConfigurationViaTeamscale.startHeartbeatThreadAndRegisterShutdownHook]. + */ + @JvmField + var configurationViaTeamscale: ConfigurationViaTeamscale? = null + + init { + setParentOutputDirectory(mainTempDirectory.resolve("coverage")) + teamscaleProxyOptionsForHttp = TeamscaleProxyOptions( + ProxySystemProperties.Protocol.HTTP, logger + ) + teamscaleProxyOptionsForHttps = TeamscaleProxyOptions( + ProxySystemProperties.Protocol.HTTPS, logger + ) + } + + /** + * Remove parts of the API key for security reasons from the options string. String is used for logging purposes. + * + * Given, for example, "config-file=jacocoagent.properties,teamscale-access-token=unlYgehaYYYhbPAegNWV3WgjOzxkmNHn" + * we produce a string with obfuscation: + * "config-file=jacocoagent.properties,teamscale-access-token=************mNHn" + */ + val obfuscatedOptionsString: String? + get() { + val original = originalOptionsString ?: return "" + + val pattern = Pattern.compile("(.*-access-token=)([^,]+)(.*)") + val match = pattern.matcher(original) + if (match.find()) { + val apiKey = match.group(2) + val obfuscatedApiKey = "************${ + apiKey.substring( + max( + 0, + apiKey.length - 4 + ) + ) + }" + return "${match.group(1)}$obfuscatedApiKey${match.group(3)}" + } + + return originalOptionsString + } + + /** + * Validates the options and returns a validator with all validation errors. + */ + val validator: Validator + get() = Validator().apply { + validateFilePaths() + validateLoggingConfig() + validateTeamscaleUploadConfig() + validateUploadConfig() + validateSapNetWeaverConfig() + if (useTestwiseCoverageMode()) { + validateTestwiseCoverageConfig() + } + } + + private fun Validator.validateFilePaths() { + classDirectoriesOrZips.forEach { path -> + isTrue(path.exists(), "Path '$path' does not exist") + isTrue(path.canRead(), "Path '$path' is not readable") + } + } + + private fun Validator.validateLoggingConfig() { + val loggingConfig = loggingConfig ?: return + ensure { + isTrue( + loggingConfig.exists(), + "The path provided for the logging configuration does not exist: $loggingConfig" + ) + isTrue( + loggingConfig.isRegularFile(), + "The path provided for the logging configuration is not a file: $loggingConfig" + ) + isTrue( + loggingConfig.isReadable(), + "The file provided for the logging configuration is not readable: $loggingConfig" + ) + isTrue( + "xml".equals(getFileExtension(loggingConfig.toFile()), ignoreCase = true), + "The logging configuration file must have the file extension .xml and be a valid XML file" + ) + } + } + + private fun Validator.validateTeamscaleUploadConfig() { + isTrue( + teamscaleServer.hasAllFieldsNull() || teamscaleServer.canConnectToTeamscale() || teamscaleServer.isConfiguredForSingleProjectTeamscaleUpload || teamscaleServer.isConfiguredForMultiProjectUpload, + "You did provide some options prefixed with 'teamscale-', but not all required ones!" + ) + isFalse( + teamscaleServer.isConfiguredForMultiProjectUpload && (teamscaleServer.revision != null + || teamscaleServer.commit != null), + "You tried to provide a commit to upload to directly. This is not possible, since you" + + " did not provide the 'teamscale-project' to upload to. Please either specify the 'teamscale-project'" + + " property, or provide the respective projects and commits via all the profiled Jar/War/Ear/...s' " + + " git.properties files." + ) + isTrue( + teamscaleServer.revision == null || teamscaleServer.commit == null, + "'" + TeamscaleConfig.TEAMSCALE_REVISION_OPTION + "' and '" + TeamscaleConfig.TEAMSCALE_REVISION_MANIFEST_JAR_OPTION + "' are incompatible with '" + TeamscaleConfig.TEAMSCALE_COMMIT_OPTION + "' and '" + + TeamscaleConfig.TEAMSCALE_COMMIT_MANIFEST_JAR_OPTION + "'." + ) + isTrue( + teamscaleServer.project == null || teamscaleServer.partition != null, + "You configured a 'teamscale-project' but no 'teamscale-partition' to upload to." + ) + } + + private fun Validator.validateUploadConfig() { + isTrue( + (artifactoryConfig.hasAllRequiredFieldsSet() || artifactoryConfig + .hasAllRequiredFieldsNull()), + String.format( + "If you want to upload data to Artifactory you need to provide " + + "'%s', '%s' and an authentication method (either '%s' and '%s' or '%s') ", + ArtifactoryConfig.ARTIFACTORY_URL_OPTION, + ArtifactoryConfig.ARTIFACTORY_PARTITION, + ArtifactoryConfig.ARTIFACTORY_USER_OPTION, ArtifactoryConfig.ARTIFACTORY_PASSWORD_OPTION, + ArtifactoryConfig.ARTIFACTORY_API_KEY_OPTION + ) + ) + isTrue( + (azureFileStorageConfig.hasAllRequiredFieldsSet() || azureFileStorageConfig + .hasAllRequiredFieldsNull()), + "If you want to upload data to an Azure file storage you need to provide both " + + "'azure-url' and 'azure-key' " + ) + val configuredStores = listOf( + artifactoryConfig.hasAllRequiredFieldsSet(), azureFileStorageConfig.hasAllRequiredFieldsSet(), + teamscaleServer.isConfiguredForSingleProjectTeamscaleUpload, + teamscaleServer.isConfiguredForMultiProjectUpload + ).count { x -> x } + + isTrue( + configuredStores <= 1, "You cannot configure multiple upload stores, " + + "such as a Teamscale instance, upload URL, Azure file storage or artifactory" + ) + } + + private fun Validator.validateSapNetWeaverConfig() { + if (sapNetWeaverJavaApplications.isEmpty()) return + + isTrue( + teamscaleServer.project == null, + "You provided an SAP NWDI applications config and a teamscale-project. This is not allowed. " + + "The project must be specified via sap-nwdi-applications!" + ) + isTrue( + teamscaleServer.project != null || teamscaleServer.isConfiguredForMultiProjectUpload, + "You provided an SAP NWDI applications config, but the 'teamscale-' upload options are incomplete." + ) + } + + private fun Validator.validateTestwiseCoverageConfig() { + isTrue( + httpServerPort != null, + "You use 'mode=testwise' but did not specify the required option 'http-server-port'!" + ) + isTrue( + testwiseCoverageMode != ETestwiseCoverageMode.TEAMSCALE_UPLOAD + || teamscaleServer.isConfiguredForSingleProjectTeamscaleUpload, + "You use 'tia-mode=teamscale-upload' but did not set all required 'teamscale-' fields to facilitate" + + " a connection to Teamscale!" + ) + isTrue( + testwiseCoverageMode != ETestwiseCoverageMode.TEAMSCALE_UPLOAD || teamscaleServer.hasCommitOrRevision(), + "You use 'tia-mode=teamscale-upload' but did not provide a revision or commit via the agent's '" + TeamscaleConfig.TEAMSCALE_REVISION_OPTION + "', '" + + TeamscaleConfig.TEAMSCALE_REVISION_MANIFEST_JAR_OPTION + "', '" + TeamscaleConfig.TEAMSCALE_COMMIT_OPTION + + "', '" + TeamscaleConfig.TEAMSCALE_COMMIT_MANIFEST_JAR_OPTION + "' or '" + + GIT_PROPERTIES_JAR_OPTION + "' option." + + " Auto-detecting the git.properties is currently not supported in this mode." + ) + } + + /** + * Creates a [TeamscaleClient] based on the agent options. Returns null if the user did not fully configure a + * Teamscale connection. + */ + fun createTeamscaleClient(requireSingleProjectUploadConfig: Boolean): TeamscaleClient? { + if (teamscaleServer.isConfiguredForSingleProjectTeamscaleUpload || + !requireSingleProjectUploadConfig && teamscaleServer.isConfiguredForServerConnection + ) { + return TeamscaleClient( + teamscaleServer.url.toString(), teamscaleServer.userName!!, + teamscaleServer.userAccessToken!!, teamscaleServer.project, + AgentUtils.USER_AGENT + ) + } + return null + } + + /** All available upload methods. */ /*package*/ + enum class EUploadMethod { + /** Saving coverage files on disk. */ + LOCAL_DISK, + /** Sending coverage to a single Teamscale project. */ + TEAMSCALE_SINGLE_PROJECT, + /** Sending coverage to multiple Teamscale projects. */ + TEAMSCALE_MULTI_PROJECT, + /** Sending coverage to multiple Teamscale projects based on SAP NWDI application definitions. */ + SAP_NWDI_TEAMSCALE, + /** Sending coverage to an Artifactory. */ + ARTIFACTORY, + /** Sending coverage to Azure file storage. */ + AZURE_FILE_STORAGE, + } + + /** Determines the upload method that should be used based on the set options. */ /*package*/ + fun determineUploadMethod() = when { + artifactoryConfig.hasAllRequiredFieldsSet() -> EUploadMethod.ARTIFACTORY + azureFileStorageConfig.hasAllRequiredFieldsSet() -> EUploadMethod.AZURE_FILE_STORAGE + !sapNetWeaverJavaApplications.isEmpty() -> EUploadMethod.SAP_NWDI_TEAMSCALE + teamscaleServer.isConfiguredForMultiProjectUpload -> EUploadMethod.TEAMSCALE_MULTI_PROJECT + teamscaleServer.isConfiguredForSingleProjectTeamscaleUpload -> EUploadMethod.TEAMSCALE_SINGLE_PROJECT + else -> EUploadMethod.LOCAL_DISK + } + + /** Creates an uploader for the coverage XMLs. */ + @Throws(UploaderException::class) + fun createUploader(instrumentation: Instrumentation?) = when (determineUploadMethod()) { + EUploadMethod.TEAMSCALE_MULTI_PROJECT -> createTeamscaleMultiProjectUploader(instrumentation) + EUploadMethod.TEAMSCALE_SINGLE_PROJECT -> createTeamscaleSingleProjectUploader(instrumentation) + EUploadMethod.ARTIFACTORY -> createArtifactoryUploader(instrumentation) + EUploadMethod.AZURE_FILE_STORAGE -> AzureFileStorageUploader( + azureFileStorageConfig, + additionalMetaDataFiles + ) + EUploadMethod.SAP_NWDI_TEAMSCALE -> { + logger.info("NWDI configuration detected. The Agent will try and auto-detect commit information by searching all profiled Jar/War/Ear/... files.") + createNwdiTeamscaleUploader(instrumentation) + } + EUploadMethod.LOCAL_DISK -> LocalDiskUploader() + } + + @Throws(UploaderException::class) + private fun createArtifactoryUploader(instrumentation: Instrumentation?): IUploader { + gitPropertiesJar?.let { jar -> + logger.info( + "You did not provide a commit to upload to directly, so the Agent will try to" + + "auto-detect it by searching the provided " + GIT_PROPERTIES_JAR_OPTION + " at " + + jar.absolutePath + " for a git.properties file." + ) + artifactoryConfig.commitInfo = ArtifactoryConfig.parseGitProperties( + jar, searchGitPropertiesRecursively, gitPropertiesCommitTimeFormat + ) + } + if (!artifactoryConfig.hasCommitInfo()) { + logger.info( + "You did not provide a commit to upload to directly, so the Agent will try and" + + " auto-detect it by searching all profiled Jar/War/Ear/... files for a git.properties file." + ) + return createDelayedArtifactoryUploader(instrumentation) + } + return ArtifactoryUploader(artifactoryConfig, additionalMetaDataFiles, reportFormat) + } + + private fun createTeamscaleSingleProjectUploader(instrumentation: Instrumentation?): IUploader { + if (teamscaleServer.hasCommitOrRevision()) { + return TeamscaleUploader(teamscaleServer) + } + + val uploader = createDelayedSingleProjectTeamscaleUploader() + + gitPropertiesJar?.let { jar -> + logger.info( + "You did not provide a commit to upload to directly, so the Agent will try to" + + "auto-detect it by searching the provided " + GIT_PROPERTIES_JAR_OPTION + " at " + + jar.absolutePath + " for a git.properties file." + ) + startGitPropertiesSearchInJarFile(uploader, jar) + return uploader + } + + logger.info( + "You did not provide a commit to upload to directly, so the Agent will try and" + + " auto-detect it by searching all profiled Jar/War/Ear/... files for a git.properties file." + ) + registerSingleGitPropertiesLocator(uploader, instrumentation) + return uploader + } + + private fun createTeamscaleMultiProjectUploader( + instrumentation: Instrumentation? + ): DelayedTeamscaleMultiProjectUploader { + val uploader = DelayedTeamscaleMultiProjectUploader { project, commitInfo -> + if (commitInfo!!.preferCommitDescriptorOverRevision || isEmpty(commitInfo.revision)) { + return@DelayedTeamscaleMultiProjectUploader teamscaleServer.withProjectAndCommit(project!!, commitInfo.commit!!) + } + teamscaleServer.withProjectAndRevision(project!!, commitInfo.revision!!) + } + + gitPropertiesJar?.let { jar -> + logger.info( + "You did not provide a Teamscale project to upload to directly, so the Agent will try and" + + " auto-detect it by searching the provided " + GIT_PROPERTIES_JAR_OPTION + " at " + + jar.absolutePath + " for a git.properties file." + ) + startMultiGitPropertiesFileSearchInJarFile(uploader, jar) + return uploader + } + logger.info( + "You did not provide a Teamscale project to upload to directly, so the Agent will try and" + + " auto-detect it by searching all profiled Jar/War/Ear/... files for git.properties files" + + " with the 'teamscale.project' field set." + ) + registerMultiGitPropertiesLocator(uploader, instrumentation) + return uploader + } + + private fun startGitPropertiesSearchInJarFile( + uploader: DelayedUploader, + gitPropertiesJar: File + ) { + val locator = GitSingleProjectPropertiesLocator( + uploader, + { file, isJarFile, recursiveSearch, timeFormat -> + getProjectRevisionsFromGitProperties(file, isJarFile, recursiveSearch, timeFormat) + }, searchGitPropertiesRecursively, + gitPropertiesCommitTimeFormat + ) + locator.searchFileForGitPropertiesAsync(gitPropertiesJar, true) + } + + private fun registerSingleGitPropertiesLocator( + uploader: DelayedUploader, + instrumentation: Instrumentation? + ) { + val locator = GitSingleProjectPropertiesLocator(uploader, + { file, isJarFile, recursiveSearch, timeFormat -> + getProjectRevisionsFromGitProperties(file, isJarFile, recursiveSearch, timeFormat) + }, searchGitPropertiesRecursively, + gitPropertiesCommitTimeFormat + ) + instrumentation?.addTransformer(GitPropertiesLocatingTransformer(locator, locationIncludeFilter)) + } + + private fun createDelayedSingleProjectTeamscaleUploader() = DelayedUploader( + { projectAndCommit: ProjectAndCommit -> + if (!isEmpty(projectAndCommit.project) && (teamscaleServer.project != projectAndCommit.project)) { + logger.warn( + "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.commitInfo!!.preferCommitDescriptorOverRevision || + isEmpty(projectAndCommit.commitInfo.revision) + ) { + teamscaleServer.commit = projectAndCommit.commitInfo.commit + } else { + teamscaleServer.revision = projectAndCommit.commitInfo.revision + } + TeamscaleUploader(teamscaleServer) + }, outputDirectory!! + ) + + private fun startMultiGitPropertiesFileSearchInJarFile( + uploader: DelayedTeamscaleMultiProjectUploader, + gitPropertiesJar: File + ) { + GitMultiProjectPropertiesLocator( + uploader, searchGitPropertiesRecursively, gitPropertiesCommitTimeFormat + ).searchFileForGitPropertiesAsync(gitPropertiesJar, true) + } + + private fun registerMultiGitPropertiesLocator( + uploader: DelayedTeamscaleMultiProjectUploader, + instrumentation: Instrumentation? + ) { + val locator = GitMultiProjectPropertiesLocator( + uploader, searchGitPropertiesRecursively, gitPropertiesCommitTimeFormat + ) + instrumentation?.addTransformer(GitPropertiesLocatingTransformer(locator, locationIncludeFilter)) + } + + private fun createDelayedArtifactoryUploader(instrumentation: Instrumentation?): IUploader { + val uploader = DelayedUploader( + { commitInfo -> + artifactoryConfig.commitInfo = commitInfo + ArtifactoryUploader(artifactoryConfig, additionalMetaDataFiles, reportFormat) + }, outputDirectory!! + ) + val locator = GitSingleProjectPropertiesLocator( + uploader, + { file, isJarFile, recursiveSearch, timeFormat -> + getCommitInfoFromGitProperties( + file, isJarFile, recursiveSearch, timeFormat + ) + }, + searchGitPropertiesRecursively, gitPropertiesCommitTimeFormat + ) + instrumentation?.addTransformer(GitPropertiesLocatingTransformer(locator, locationIncludeFilter)) + return uploader + } + + private fun createNwdiTeamscaleUploader(instrumentation: Instrumentation?): IUploader { + val uploader = DelayedSapNwdiMultiUploader { commit, application -> + TeamscaleUploader( + teamscaleServer.withProjectAndCommit(application.teamscaleProject, commit) + ) + } + instrumentation?.addTransformer( + NwdiMarkerClassLocatingTransformer( + uploader, locationIncludeFilter, sapNetWeaverJavaApplications + ) + ) + return uploader + } + + private val reportFormat: EReportFormat + get() = if (useTestwiseCoverageMode()) { + EReportFormat.TESTWISE_COVERAGE + } else EReportFormat.JACOCO + + /** + * Creates a new file with the given prefix, extension and current timestamp and ensures that the parent folder + * actually exists. + */ + @Throws(IOException::class) + fun createNewFileInOutputDirectory(prefix: String, extension: String): File { + ensureDirectoryExists(outputDirectory!!.toFile()) + return outputDirectory!!.resolve( + "$prefix-${LocalDateTime.now().format(DATE_TIME_FORMATTER)}.$extension" + ).toFile() + } + + /** + * Creates a new file with the given prefix, extension and current timestamp and ensures that the parent folder + * actually exists. One output folder is created per partition. + */ + @Throws(IOException::class) + fun createNewFileInPartitionOutputDirectory(prefix: String, extension: String): File { + val partitionOutputDir = outputDirectory!!.resolve( + safeFolderName(teamscaleServer.partition!!) + ) + ensureDirectoryExists(partitionOutputDir.toFile()) + return partitionOutputDir.resolve( + "$prefix-${LocalDateTime.now().format(DATE_TIME_FORMATTER)}.$extension" + ).toFile() + } + + /** + * Sets the parent of the output directory for this run. The output directory itself will be created in this folder + * is named after the current timestamp with the format yyyy-MM-dd-HH-mm-ss.SSS + */ + fun setParentOutputDirectory(outputDirectoryParent: Path) { + outputDirectory = outputDirectoryParent.resolve(LocalDateTime.now().format(DATE_TIME_FORMATTER)) + } + + /** Returns whether the config indicates to use Test Impact mode. */ + fun useTestwiseCoverageMode() = mode == EMode.TESTWISE + + val locationIncludeFilter: ClasspathWildcardIncludeFilter + get() = ClasspathWildcardIncludeFilter(jacocoIncludes, jacocoExcludes) + + /** Whether coverage should be dumped in regular intervals. */ + fun shouldDumpInIntervals() = dumpIntervalInMinutes > 0 + + /** @return the [TeamscaleProxyOptions] for the given protocol. */ + fun getTeamscaleProxyOptions(protocol: ProxySystemProperties.Protocol?) = + if (protocol == ProxySystemProperties.Protocol.HTTP) { + teamscaleProxyOptionsForHttp + } else teamscaleProxyOptionsForHttps + + companion object { + /** + * Can be used to format [java.time.LocalDate] to the format "yyyy-MM-dd-HH-mm-ss.SSS" + */ + val DATE_TIME_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd-HH-mm-ss.SSS", Locale.ENGLISH) + + /** + * The default excludes applied to JaCoCo. These are packages that should never be profiled. Excluding them makes + * debugging traces easier and reduces trace size and warnings about unmatched classes in Teamscale. + */ + const val DEFAULT_EXCLUDES = + "shadow.*:com.sun.*:sun.*:org.eclipse.*:org.junit.*:junit.*:org.apache.*:org.slf4j.*:javax.*:org.gradle.*:java.*:org.jboss.*:org.wildfly.*:org.springframework.*:com.fasterxml.*:jakarta.*:org.aspectj.*:org.h2.*:org.hibernate.*:org.assertj.*:org.mockito.*:org.thymeleaf.*" + + /** Option name that allows to specify a jar file that contains the git commit hash in a git.properties file. */ + const val GIT_PROPERTIES_JAR_OPTION = "git-properties-jar" + + /** + * Specifies the date format in which the commit timestamp in the git.properties file is formatted. + */ + const val GIT_PROPERTIES_COMMIT_DATE_FORMAT_OPTION = "git-properties-commit-date-format" + + private fun safeFolderName(folderName: String): Path { + val result = folderName.replace("[<>:\"/|?*]".toRegex(), "") + .replace("\\.+".toRegex(), "dot") + .replace("\\x00".toRegex(), "") + .replace("[. ]$".toRegex(), "") + + return if (result.isEmpty()) { + Paths.get("default") + } else { + Paths.get(result) + } + } + } +} diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/AgentOptionsParser.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/AgentOptionsParser.kt new file mode 100644 index 000000000..4801977f4 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/AgentOptionsParser.kt @@ -0,0 +1,535 @@ +package com.teamscale.jacoco.agent.options + +import com.teamscale.client.FileSystemUtils.readFileUTF8 +import com.teamscale.client.HttpUtils.setShouldValidateSsl +import com.teamscale.client.ProxySystemProperties +import com.teamscale.client.StringUtils.isEmpty +import com.teamscale.client.StringUtils.splitLinesAsList +import com.teamscale.client.StringUtils.stripPrefix +import com.teamscale.jacoco.agent.configuration.AgentOptionReceiveException +import com.teamscale.jacoco.agent.configuration.ConfigurationViaTeamscale +import com.teamscale.jacoco.agent.options.sapnwdi.SapNwdiApplication.Companion.parseApplications +import com.teamscale.jacoco.agent.upload.artifactory.ArtifactoryConfig +import com.teamscale.jacoco.agent.upload.artifactory.ArtifactoryConfig.Companion.handleArtifactoryOptions +import com.teamscale.jacoco.agent.upload.azure.AzureFileStorageConfig.Companion.handleAzureFileStorageOptions +import com.teamscale.jacoco.agent.upload.teamscale.TeamscaleConfig +import com.teamscale.report.util.ILogger +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import org.jetbrains.annotations.VisibleForTesting +import java.io.File +import java.io.FileNotFoundException +import java.io.IOException +import java.nio.file.InvalidPathException +import java.nio.file.Path +import java.nio.file.Paths +import java.time.format.DateTimeFormatter +import java.util.* + +/** + * Parses agent command line options. + */ +class AgentOptionsParser @VisibleForTesting internal constructor( + private val logger: ILogger, + private val environmentConfigId: String?, + private val environmentConfigFile: String?, + private val credentials: TeamscaleCredentials?, + private val environmentAccessToken: String? +) { + private val filePatternResolver = FilePatternResolver(logger) + private val teamscaleConfig = TeamscaleConfig(logger, filePatternResolver) + private val collectedErrors = mutableListOf() + + /** + * Throw the first collected exception, if present. + */ + @VisibleForTesting + @Throws(Exception::class) + fun throwOnCollectedErrors() { + collectedErrors.forEach { throw it } + } + + /** + * Parses the given command-line options. + */ + /* package */ + @Throws(AgentOptionParseException::class, AgentOptionReceiveException::class) + fun parse(optionsString: String?): AgentOptions { + var optionsString = optionsString + if (optionsString == null) { + optionsString = "" + } + logger.debug("Parsing options: $optionsString") + + val options = AgentOptions(logger) + options.originalOptionsString = optionsString + + presetCredentialOptions(options) + + if (!isEmpty(optionsString)) { + val optionParts = optionsString.split(",".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + optionParts.forEach { optionPart -> + try { + handleOptionPart(options, optionPart) + } catch (e: Exception) { + collectedErrors.add(e) + } + } + } + + // we have to put the proxy options into system properties before reading the configuration from Teamscale as we + // might need them to connect to Teamscale + putTeamscaleProxyOptionsIntoSystemProperties(options) + + handleConfigId(options) + handleConfigFile(options) + + val validator = options.validator + if (!validator.isValid) { + collectedErrors.add(AgentOptionParseException("Invalid options given: ${validator.errorMessage}")) + } + + return options + } + + private fun presetCredentialOptions(options: AgentOptions) { + if (credentials != null) { + options.teamscaleServer.url = credentials.url + options.teamscaleServer.userName = credentials.userName + options.teamscaleServer.userAccessToken = credentials.accessKey + } + if (environmentAccessToken != null) { + options.teamscaleServer.userAccessToken = environmentAccessToken + } + } + + @Throws(AgentOptionReceiveException::class, AgentOptionParseException::class) + private fun handleConfigId(options: AgentOptions) { + if (environmentConfigId != null) { + if (options.teamscaleServer.configId != null) { + logger.warn( + "You specified an ID for a profiler configuration in Teamscale both in the agent options and using an environment variable." + + " The environment variable will override the ID specified using the agent options." + + " Please use one or the other." + ) + } + handleOptionPart(options, "config-id=$environmentConfigId") + } + + readConfigFromTeamscale(options) + } + + @Throws(AgentOptionParseException::class) + private fun handleConfigFile(options: AgentOptions) { + if (environmentConfigFile != null) { + handleOptionPart(options, "config-file=$environmentConfigFile") + } + + if (environmentConfigId != null && environmentConfigFile != null) { + logger.warn( + "You specified both an ID for a profiler configuration in Teamscale and a config file." + + " The config file will override the Teamscale configuration." + + " Please use one or the other." + ) + } + } + + /** + * Parses and stores the given option in the format `key=value`. + */ + @Throws(AgentOptionParseException::class) + private fun handleOptionPart( + options: AgentOptions, + optionPart: String + ) { + val (key, value) = parseOption(optionPart) + handleOption(options, key, value) + } + + /** + * Parses and stores the option with the given key and value. + */ + @Throws(AgentOptionParseException::class) + private fun handleOption( + options: AgentOptions, + key: String, value: String + ) { + if (key.startsWith(DEBUG)) { + handleDebugOption(options, value) + return + } + if (key.startsWith("jacoco-")) { + options.additionalJacocoOptions.add(key.substring(7) to value) + return + } + if (key.startsWith("teamscale-") && teamscaleConfig.handleTeamscaleOptions(options.teamscaleServer, key, value)) return + if (key.startsWith("artifactory-") && handleArtifactoryOptions(options.artifactoryConfig, key, value)) return + if (key.startsWith("azure-") && handleAzureFileStorageOptions(options.azureFileStorageConfig, key, value)) return + if (key.startsWith("proxy-") && handleProxyOptions(options, stripPrefix(key, "proxy-"), value)) return + if (handleAgentOptions(options, key, value)) return + throw AgentOptionParseException("Unknown option: $key") + } + + @Throws(AgentOptionParseException::class) + private fun handleProxyOptions( + options: AgentOptions, key: String, value: String + ): Boolean { + val httpsPrefix = "${ProxySystemProperties.Protocol.HTTPS}-" + if (key.startsWith(httpsPrefix) + && options.getTeamscaleProxyOptions(ProxySystemProperties.Protocol.HTTPS)!! + .handleTeamscaleProxyOptions(stripPrefix(key, httpsPrefix), value) + ) return true + + val httpPrefix = ProxySystemProperties.Protocol.HTTP.toString() + "-" + if (key.startsWith(httpPrefix) + && options.getTeamscaleProxyOptions(ProxySystemProperties.Protocol.HTTP)!! + .handleTeamscaleProxyOptions(stripPrefix(key, httpPrefix), value) + ) return true + + if (key == "password-file") { + val proxyPasswordPath = parsePath(filePatternResolver, key, value) + options.getTeamscaleProxyOptions(ProxySystemProperties.Protocol.HTTPS)!! + .proxyPasswordPath = proxyPasswordPath + options.getTeamscaleProxyOptions(ProxySystemProperties.Protocol.HTTP)!! + .proxyPasswordPath = proxyPasswordPath + return true + } + return false + } + + /** Parses and stores the debug logging file path if given. */ + private fun handleDebugOption(options: AgentOptions, value: String) { + if (value.equals("false", ignoreCase = true)) return + options.isDebugLogging = true + if (!value.isEmpty() && !value.equals("true", ignoreCase = true)) { + options.debugLogDirectory = Paths.get(value) + } + } + + @Throws(AgentOptionParseException::class) + private fun parseOption(optionPart: String): Pair { + val keyAndValue = optionPart.split("=".toRegex(), limit = 2).toTypedArray() + if (keyAndValue.size < 2) { + throw AgentOptionParseException("Got an option without any value: $optionPart") + } + + val key = keyAndValue[0].lowercase(Locale.getDefault()) + var value = keyAndValue[1] + + // Remove quotes, which may be used to pass arguments with spaces via + // the command line + if (value.startsWith("\"") && value.endsWith("\"")) { + value = value.substring(1, value.length - 1) + } + return key to value + } + + /** + * Handles all common command line options for the agent. + * + * @return true if it has successfully processed the given option. + */ + @Throws(AgentOptionParseException::class) + private fun handleAgentOptions(options: AgentOptions, key: String, value: String): Boolean { + when (key) { + "config-id" -> { + options.teamscaleServer.configId = value + return true + } + CONFIG_FILE_OPTION -> { + readConfigFromFile(options, parsePath(filePatternResolver, key, value)!!.toFile()) + return true + } + LOGGING_CONFIG_OPTION -> { + options.loggingConfig = parsePath(filePatternResolver, key, value) + return true + } + "interval" -> { + options.dumpIntervalInMinutes = parseInt(key, value) + return true + } + "validate-ssl" -> { + options.validateSsl = value.toBoolean() + return true + } + "out" -> { + options.setParentOutputDirectory(parsePath(filePatternResolver, key, value)!!) + return true + } + "upload-metadata" -> { + try { + options.additionalMetaDataFiles = splitMultiOptionValue(value).map { path -> Paths.get(path) } + } catch (e: InvalidPathException) { + throw AgentOptionParseException("Invalid path given for option 'upload-metadata'", e) + } + return true + } + "duplicates" -> { + options.duplicateClassFileBehavior = parseEnumValue(key, value) + return true + } + "ignore-uncovered-classes" -> { + options.ignoreUncoveredClasses = value.toBoolean() + return true + } + "obfuscate-security-related-outputs" -> { + options.obfuscateSecurityRelatedOutputs = value.toBoolean() + return true + } + "dump-on-exit" -> { + options.shouldDumpOnExit = value.toBoolean() + return true + } + "search-git-properties-recursively" -> { + options.searchGitPropertiesRecursively = value.toBoolean() + return true + } + ArtifactoryConfig.ARTIFACTORY_GIT_PROPERTIES_JAR_OPTION -> { + logger.warn( + "The option " + ArtifactoryConfig.ARTIFACTORY_GIT_PROPERTIES_JAR_OPTION + " is deprecated. It still has an effect, " + + "but should be replaced with the equivalent option " + AgentOptions.GIT_PROPERTIES_JAR_OPTION + "." + ) + options.gitPropertiesJar = getGitPropertiesJarFile(value) + return true + } + AgentOptions.GIT_PROPERTIES_JAR_OPTION -> { + options.gitPropertiesJar = getGitPropertiesJarFile(value) + return true + } + ArtifactoryConfig.ARTIFACTORY_GIT_PROPERTIES_COMMIT_DATE_FORMAT_OPTION -> { + logger.warn( + "The option " + ArtifactoryConfig.ARTIFACTORY_GIT_PROPERTIES_COMMIT_DATE_FORMAT_OPTION + " is deprecated. It still has an effect, " + + "but should be replaced with the equivalent option " + AgentOptions.GIT_PROPERTIES_COMMIT_DATE_FORMAT_OPTION + "." + ) + options.gitPropertiesCommitTimeFormat = DateTimeFormatter.ofPattern(value) + return true + } + AgentOptions.GIT_PROPERTIES_COMMIT_DATE_FORMAT_OPTION -> { + options.gitPropertiesCommitTimeFormat = DateTimeFormatter.ofPattern(value) + return true + } + "mode" -> { + options.mode = parseEnumValue(key, value) + return true + } + "includes" -> { + options.jacocoIncludes = value.replace(";".toRegex(), ":") + return true + } + "excludes" -> { + options.jacocoExcludes = value.replace(";".toRegex(), ":") + ":" + AgentOptions.DEFAULT_EXCLUDES + return true + } + "class-dir" -> { + val list = splitMultiOptionValue(value) + try { + options.classDirectoriesOrZips = ClasspathUtils.resolveClasspathTextFiles( + key, filePatternResolver, list + ) + } catch (e: IOException) { + throw AgentOptionParseException(e) + } + return true + } + "http-server-port" -> { + options.httpServerPort = parseInt(key, value) + return true + } + "sap-nwdi-applications" -> { + options.sapNetWeaverJavaApplications = parseApplications(value) + return true + } + "tia-mode" -> { + options.testwiseCoverageMode = parseEnumValue(key, value) + return true + } + else -> return false + } + } + + @Throws(AgentOptionParseException::class, AgentOptionReceiveException::class) + private fun readConfigFromTeamscale(options: AgentOptions) { + if (options.teamscaleServer.configId == null) return + if (!options.teamscaleServer.isConfiguredForServerConnection) { + throw AgentOptionParseException( + "Config-id '${options.teamscaleServer.configId}' specified without teamscale url/user/accessKey! These options must be provided locally via config-file or command line argument." + ) + } + // Set ssl validation option in case it needs to be off before trying to reach Teamscale. + setShouldValidateSsl(options.validateSsl) + val configuration = ConfigurationViaTeamscale.retrieve( + logger, + options.teamscaleServer.configId, + options.teamscaleServer.url!!, + options.teamscaleServer.userName!!, + options.teamscaleServer.userAccessToken!! + ) + options.configurationViaTeamscale = configuration + logger.debug("Received the following options from Teamscale: ${configuration.profilerConfiguration!!.configurationOptions}") + readConfigFromString(options, configuration.profilerConfiguration!!.configurationOptions) + } + + private fun getGitPropertiesJarFile(path: String): File? { + val jarFile = File(path) + if (!jarFile.exists()) { + logger.warn("The path provided with the ${AgentOptions.GIT_PROPERTIES_JAR_OPTION} option does not exist: $path. Continuing without searching it for git.properties files.") + return null + } + if (!jarFile.isFile()) { + logger.warn("The path provided with the ${AgentOptions.GIT_PROPERTIES_JAR_OPTION} option is not a regular file (probably a folder instead): $path. Continuing without searching it for git.properties files.") + return null + } + return jarFile + } + + /** + * Reads configuration parameters from the given file. The expected format is basically the same as for the command + * line, but line breaks are also considered as separators. e.g. class-dir=out # Some comment includes=test.* + * excludes=third.party.* + */ + @Throws(AgentOptionParseException::class) + private fun readConfigFromFile( + options: AgentOptions, configFile: File + ) { + try { + val content = readFileUTF8(configFile) + readConfigFromString(options, content) + } catch (e: FileNotFoundException) { + throw AgentOptionParseException("File ${configFile.absolutePath} given for option 'config-file' not found", e) + } catch (e: IOException) { + throw AgentOptionParseException("An error occurred while reading the config file ${configFile.absolutePath}", e) + } + } + + private fun readConfigFromString(options: AgentOptions, content: String?) { + splitLinesAsList(content).forEach { optionKeyValue -> + try { + val trimmedOption = optionKeyValue.trim { it <= ' ' } + if (trimmedOption.isEmpty() || trimmedOption.startsWith(COMMENT_PREFIX)) { + return@forEach + } + handleOptionPart(options, optionKeyValue) + } catch (e: Exception) { + collectedErrors.add(e) + } + } + } + + @Throws(AgentOptionParseException::class) + private fun parseInt(key: String?, value: String): Int { + try { + return value.toInt() + } catch (_: NumberFormatException) { + throw AgentOptionParseException("Invalid non-numeric value for option `${key}`: $value") + } + } + + companion object { + /** The name of the option for providing the logging config. */ + const val LOGGING_CONFIG_OPTION: String = "logging-config" + + /** The name of the option for providing the config file. */ + const val CONFIG_FILE_OPTION: String = "config-file" + + /** Character which starts a comment in the config file. */ + private const val COMMENT_PREFIX = "#" + + /** The name of the option that enables debug logging. */ + const val DEBUG: String = "debug" + + /** + * Parses the given command-line options. + * + * @param environmentConfigId The Profiler configuration ID given via an environment variable. + * @param environmentConfigFile The Profiler configuration file given via an environment variable. + * @param credentials Optional Teamscale credentials from a teamscale.properties file. + * @param environmentAccessToken Optional access token for accessing Teamscale, read from an env variable. + */ + @Throws(AgentOptionParseException::class, AgentOptionReceiveException::class) + fun parse( + optionsString: String, + environmentConfigId: String?, + environmentConfigFile: String?, credentials: TeamscaleCredentials?, + environmentAccessToken: String?, + logger: ILogger + ): Pair> { + val parser = AgentOptionsParser( + logger, environmentConfigId, environmentConfigFile, + credentials, environmentAccessToken + ) + val options = parser.parse(optionsString) + return options to parser.collectedErrors + } + + /** + * Stores the agent options for proxies in the [com.teamscale.client.TeamscaleProxySystemProperties] and overwrites the password + * with the password found in the proxy-password-file if necessary. + */ + @JvmStatic + @VisibleForTesting + fun putTeamscaleProxyOptionsIntoSystemProperties(options: AgentOptions) { + options.getTeamscaleProxyOptions(ProxySystemProperties.Protocol.HTTP) + ?.putTeamscaleProxyOptionsIntoSystemProperties() + options.getTeamscaleProxyOptions(ProxySystemProperties.Protocol.HTTPS) + ?.putTeamscaleProxyOptionsIntoSystemProperties() + } + + /** + * Interprets the given pattern as an Ant pattern and resolves it to one existing [Path]. If the given path is + * relative, it is resolved relative to the current working directory. If more than one file matches the pattern, + * one of the matching files is used without any guarantees as to which. The selection is, however, guaranteed to be + * deterministic, i.e. if you run the pattern twice and get the same set of files, the same file will be picked each + * time. + */ + @Throws(AgentOptionParseException::class) + fun parsePath( + filePatternResolver: FilePatternResolver, + optionName: String?, + pattern: String? + ): Path? { + try { + return filePatternResolver.parsePath(optionName, pattern) + } catch (e: IOException) { + throw AgentOptionParseException(e) + } + } + + /** + * Parses the given value as a URL. + */ + @Throws(AgentOptionParseException::class) + fun parseUrl(key: String?, value: String): HttpUrl { + var value = value + if (!value.endsWith("/")) { + value += "/" + } + + // default to HTTP if no scheme is given and port is not 443, default to HTTPS if no scheme is given AND port is 443 + if (!value.startsWith("http://") && !value.startsWith("https://")) { + val url: HttpUrl = getUrl(key, "http://$value") + value = if (url.port == 443) { + "https://$value" + } else { + "http://$value" + } + } + + return getUrl(key, value) + } + + @Throws(AgentOptionParseException::class) + private fun getUrl(key: String?, value: String) = value.toHttpUrlOrNull() ?: throw AgentOptionParseException("Invalid URL given for option '$key'") + + /** + * Splits the given value at semicolons. + */ + private fun splitMultiOptionValue(value: String) = value.split(";".toRegex()).dropLastWhile { it.isEmpty() } + + inline fun > parseEnumValue(key: String, value: String) = try { + enumValueOf(value.uppercase().replace("-", "_")) + } catch (e: IllegalArgumentException) { + val validValues = enumValues().joinToString(", ") { it.name } + throw AgentOptionParseException("Invalid value for option `$key`. Valid values: $validValues", e) + } + } +} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/options/EMode.java b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/EMode.kt similarity index 86% rename from agent/src/main/java/com/teamscale/jacoco/agent/options/EMode.java rename to agent/src/main/kotlin/com/teamscale/jacoco/agent/options/EMode.kt index dcfe86b87..7bb684400 100644 --- a/agent/src/main/java/com/teamscale/jacoco/agent/options/EMode.java +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/EMode.kt @@ -1,14 +1,12 @@ -package com.teamscale.jacoco.agent.options; - -/** Describes the two possible modes the agent can be started in. */ -public enum EMode { +package com.teamscale.jacoco.agent.options +/** Describes the two possible modes the agent can be started in. */ +enum class EMode { /** * The default mode which produces JaCoCo XML coverage files on exit, in a defined interval or when triggered via an * HTTP endpoint. Each dump produces a new file containing the all collected coverage. */ NORMAL, - /** * Testwise coverage mode in which the agent only dumps when triggered via an HTTP endpoint. Coverage is written as * exec and appended into a single file. diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/ETestwiseCoverageMode.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/ETestwiseCoverageMode.kt new file mode 100644 index 000000000..805a00238 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/ETestwiseCoverageMode.kt @@ -0,0 +1,13 @@ +package com.teamscale.jacoco.agent.options + +/** Decides which [com.teamscale.jacoco.agent.testimpact.TestEventHandlerStrategyBase] is used in testwise mode. */ +enum class ETestwiseCoverageMode { + /** Caches testwise coverage in-memory and uploads a report to Teamscale. */ + TEAMSCALE_UPLOAD, + /** Writes testwise coverage to disk as .json files. */ + DISK, + /** Writes testwise coverage to disk as .exec files. */ + EXEC_FILE, + /** Returns testwise coverage to the caller via HTTP. */ + HTTP +} diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/JacocoAgentOptionsBuilder.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/JacocoAgentOptionsBuilder.kt new file mode 100644 index 000000000..c6965fd7e --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/JacocoAgentOptionsBuilder.kt @@ -0,0 +1,88 @@ +package com.teamscale.jacoco.agent.options + +import com.teamscale.jacoco.agent.logging.LoggingUtils.getLogger +import com.teamscale.jacoco.agent.util.AgentUtils.agentDirectory +import com.teamscale.jacoco.agent.util.AgentUtils.mainTempDirectory +import java.io.File +import java.io.IOException +import java.nio.file.Files +import java.nio.file.Path +import java.util.function.Consumer + +/** Builder for the JaCoCo agent options string. */ +class JacocoAgentOptionsBuilder(private val agentOptions: AgentOptions) { + private val logger = getLogger(this) + + /** + * Returns the options to pass to the JaCoCo agent. + */ + @Throws(AgentOptionParseException::class, IOException::class) + fun createJacocoAgentOptions(): String { + val builder = StringBuilder(modeSpecificOptions) + if (agentOptions.jacocoIncludes != null) { + builder.append(",includes=").append(agentOptions.jacocoIncludes) + } + + logger.debug("Using default excludes: ${AgentOptions.DEFAULT_EXCLUDES}") + builder.append(",excludes=").append(agentOptions.jacocoExcludes) + + // Don't dump class files in testwise mode when coverage is written to an exec file + val needsClassFiles = + agentOptions.mode == EMode.NORMAL || agentOptions.testwiseCoverageMode != ETestwiseCoverageMode.EXEC_FILE + if (agentOptions.classDirectoriesOrZips.isEmpty() && needsClassFiles) { + val tempDir = createTemporaryDumpDirectory() + tempDir.toFile().deleteOnExit() + builder.append(",classdumpdir=").append(tempDir.toAbsolutePath()) + + agentOptions.classDirectoriesOrZips = mutableListOf(tempDir.toFile()) + } + + agentOptions.additionalJacocoOptions.forEach { pair -> + builder.append(",").append(pair.first).append("=").append(pair.second) + } + + return builder.toString() + } + + @Throws(AgentOptionParseException::class) + private fun createTemporaryDumpDirectory(): Path { + try { + return Files.createDirectory(mainTempDirectory.resolve("jacoco-class-dump")) + } catch (_: IOException) { + logger.warn("Unable to create temporary directory in default location. Trying in system temp directory.") + } + + try { + return Files.createTempDirectory("jacoco-class-dump") + } catch (_: IOException) { + logger.warn("Unable to create temporary directory in default location. Trying in output directory.") + } + + try { + return Files.createTempDirectory(agentOptions.outputDirectory, "jacoco-class-dump") + } catch (_: IOException) { + logger.warn("Unable to create temporary directory in output directory. Trying in agent's directory.") + } + + val agentDirectory = agentDirectory + try { + return Files.createTempDirectory(agentDirectory, "jacoco-class-dump") + } catch (e: IOException) { + throw AgentOptionParseException("Unable to create a temporary directory anywhere", e) + } + } + + /** + * Returns additional options for JaCoCo depending on the selected [AgentOptions.mode] and + * [AgentOptions.testwiseCoverageMode]. + */ + @get:Throws(IOException::class) + val modeSpecificOptions: String + get() = if (agentOptions.useTestwiseCoverageMode() && agentOptions.testwiseCoverageMode == ETestwiseCoverageMode.EXEC_FILE) { + // when writing to a .exec file, we can instruct JaCoCo to do so directly + "destfile=${agentOptions.createNewFileInOutputDirectory("jacoco", "exec").absolutePath}" + } else { + // otherwise we don't need JaCoCo to perform any output of the .exec information + "output=none" + } +} 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/options/TeamscaleCredentials.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/TeamscaleCredentials.kt new file mode 100644 index 000000000..7405c4ae3 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/TeamscaleCredentials.kt @@ -0,0 +1,13 @@ +package com.teamscale.jacoco.agent.options + +import okhttp3.HttpUrl + +/** Credentials for accessing a Teamscale instance. */ +class TeamscaleCredentials( + /** The URL of the Teamscale server. */ + @JvmField val url: HttpUrl?, + /** The user name used to authenticate against Teamscale. */ + @JvmField val userName: String?, + /** The user's access key. */ + @JvmField val accessKey: String? +) diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/TeamscalePropertiesUtils.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/TeamscalePropertiesUtils.kt new file mode 100644 index 000000000..3b05c3c06 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/TeamscalePropertiesUtils.kt @@ -0,0 +1,71 @@ +package com.teamscale.jacoco.agent.options + +import com.teamscale.client.FileSystemUtils.readProperties +import com.teamscale.jacoco.agent.util.AgentUtils.agentDirectory +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import java.io.IOException +import java.nio.file.Files +import java.nio.file.Path +import java.util.* +import kotlin.io.path.exists + +/** + * Utilities for working with the teamscale.properties file that contains access credentials for the Teamscale + * instance. + */ +object TeamscalePropertiesUtils { + private val TEAMSCALE_PROPERTIES_PATH = agentDirectory.resolve("teamscale.properties") + + /** + * Tries to open [.TEAMSCALE_PROPERTIES_PATH] and parse that properties file to obtain + * [TeamscaleCredentials]. + * + * @return the parsed credentials or null in case the teamscale.properties file doesn't exist. + * @throws AgentOptionParseException in case the teamscale.properties file exists but can't be read or parsed. + */ + @Throws(AgentOptionParseException::class) + fun parseCredentials() = parseCredentials(TEAMSCALE_PROPERTIES_PATH) + + /** + * Same as [.parseCredentials] but testable since the path is not hardcoded. + */ + /*package*/ + @JvmStatic + @Throws(AgentOptionParseException::class) + fun parseCredentials( + teamscalePropertiesPath: Path + ): TeamscaleCredentials? { + if (!teamscalePropertiesPath.exists()) { + return null + } + + try { + val properties = readProperties(teamscalePropertiesPath.toFile()) + return parseProperties(properties) + } catch (e: IOException) { + throw AgentOptionParseException("Failed to read $teamscalePropertiesPath", e) + } + } + + @Throws(AgentOptionParseException::class) + private fun parseProperties(properties: Properties): TeamscaleCredentials { + val urlString = properties.getProperty("url") + ?: throw AgentOptionParseException("teamscale.properties is missing the url field") + + val url: HttpUrl + try { + url = urlString.toHttpUrl() + } catch (e: IllegalArgumentException) { + throw AgentOptionParseException("teamscale.properties contained malformed URL $urlString", e) + } + + val userName = properties.getProperty("username") + ?: throw AgentOptionParseException("teamscale.properties is missing the username field") + + val accessKey = properties.getProperty("accesskey") + ?: throw AgentOptionParseException("teamscale.properties is missing the accesskey field") + + return TeamscaleCredentials(url, userName, accessKey) + } +} diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/TeamscaleProxyOptions.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/TeamscaleProxyOptions.kt new file mode 100644 index 000000000..4aa6febc6 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/TeamscaleProxyOptions.kt @@ -0,0 +1,117 @@ +package com.teamscale.jacoco.agent.options + +import com.teamscale.client.FileSystemUtils.readFileUTF8 +import com.teamscale.client.ProxySystemProperties +import com.teamscale.client.StringUtils.isEmpty +import com.teamscale.client.TeamscaleProxySystemProperties +import com.teamscale.report.util.ILogger +import java.io.IOException +import java.nio.file.Path + +/** + * Parses agent command line options related to the proxy settings. + */ +class TeamscaleProxyOptions(private val protocol: ProxySystemProperties.Protocol, private val logger: ILogger) { + /** The host of the proxy server. */ /* package */ + @JvmField + var proxyHost: String? + + /** The port of the proxy server. */ /* package */ + @JvmField + var proxyPort: Int = 0 + + /** The password for the proxy user. */ /* package */ + @JvmField + var proxyPassword: String? + + /** A path to the file that contains the password for the proxy authentication. */ /* package */ + var proxyPasswordPath: Path? = null + + /** The username of the proxy user. */ /* package */ + @JvmField + var proxyUser: String? + + /** Constructor. */ + init { + val proxySystemProperties = ProxySystemProperties(protocol) + proxyHost = proxySystemProperties.proxyHost + try { + proxyPort = proxySystemProperties.proxyPort + } catch (e: ProxySystemProperties.IncorrectPortFormatException) { + proxyPort = -1 + logger.warn(e.message!!) + } + proxyUser = proxySystemProperties.proxyUser + proxyPassword = proxySystemProperties.proxyPassword + } + + /** + * Processes the command-line options for proxies. + * + * @return true if it has successfully processed the given option. + */ + @Throws(AgentOptionParseException::class) + fun handleTeamscaleProxyOptions(key: String?, value: String): Boolean { + if ("host" == key) { + proxyHost = value + return true + } + val proxyPortOption = "port" + if (proxyPortOption == key) { + try { + proxyPort = value.toInt() + } catch (e: NumberFormatException) { + throw AgentOptionParseException( + "Could not parse proxy port \"$value\" set via \"$proxyPortOption\"", e + ) + } + return true + } + if ("user" == key) { + proxyUser = value + return true + } else if ("password" == key) { + proxyPassword = value + return true + } + return false + } + + /** Stores the teamscale-specific proxy settings as system properties to make them always available. */ + fun putTeamscaleProxyOptionsIntoSystemProperties() { + val teamscaleProxySystemProperties = TeamscaleProxySystemProperties(protocol) + if (!isEmpty(proxyHost)) { + teamscaleProxySystemProperties.proxyHost = proxyHost + } + if (proxyPort > 0) { + teamscaleProxySystemProperties.proxyPort = proxyPort + } + if (!isEmpty(proxyUser)) { + teamscaleProxySystemProperties.proxyUser = proxyUser + } + if (!isEmpty(proxyPassword)) { + teamscaleProxySystemProperties.proxyPassword = proxyPassword + } + + setProxyPasswordFromFile(proxyPasswordPath) + } + + /** + * Sets the proxy password JVM property from a file for the protocol in this instance of + * [TeamscaleProxyOptions]. + */ + private fun setProxyPasswordFromFile(proxyPasswordFilePath: Path?) { + if (proxyPasswordFilePath == null) { + return + } + try { + val proxyPassword = readFileUTF8(proxyPasswordFilePath.toFile()).trim() + TeamscaleProxySystemProperties(protocol).proxyPassword = proxyPassword + } catch (e: IOException) { + logger.error( + "Unable to open file containing proxy password. Please make sure the file exists and the user has the permissions to read the file.", + e + ) + } + } +} diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/sapnwdi/DelayedSapNwdiMultiUploader.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/sapnwdi/DelayedSapNwdiMultiUploader.kt new file mode 100644 index 000000000..1146931be --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/sapnwdi/DelayedSapNwdiMultiUploader.kt @@ -0,0 +1,48 @@ +package com.teamscale.jacoco.agent.options.sapnwdi + +import com.teamscale.client.CommitDescriptor +import com.teamscale.jacoco.agent.upload.DelayedMultiUploaderBase +import com.teamscale.jacoco.agent.upload.IUploader +import java.util.function.BiFunction + +/** + * Wraps multiple [IUploader]s to delay uploads until a [CommitDescriptor] is asynchronously made + * available for each application. Whenever a dump happens, the coverage is uploaded to all projects for which a + * corresponding commit has already been found. Uploads for application that have not committed at that time are skipped. + * + * + * This is safe assuming that the marker class is the central entry point for the application, and therefore there should + * not be any relevant coverage for the application as long as the marker class has not been loaded. + */ +class DelayedSapNwdiMultiUploader( + private val uploaderFactory: BiFunction +) : DelayedMultiUploaderBase(), IUploader { + /** The wrapped uploader instances. */ + private val uploaders = mutableMapOf() + + /** + * Visible for testing. Allows tests to control the [Executor] to test the asynchronous functionality of this + * class. + */ + init { + registerShutdownHook() + } + + /** Registers the shutdown hook. */ + private fun registerShutdownHook() { + Runtime.getRuntime().addShutdownHook(Thread { + if (wrappedUploaders.isEmpty()) { + logger.error("The application was shut down before a commit could be found. The recorded coverage is lost.") + } + }) + } + + /** Sets the commit info detected for the application. */ + fun setCommitForApplication(commit: CommitDescriptor, application: SapNwdiApplication) { + logger.info("Found commit for ${application.markerClass}: $commit") + uploaders[application] = uploaderFactory.apply(commit, application) + } + + override val wrappedUploaders: MutableCollection + get() = uploaders.values +} diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/sapnwdi/SapNwdiApplication.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/sapnwdi/SapNwdiApplication.kt new file mode 100644 index 000000000..4890f0f40 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/sapnwdi/SapNwdiApplication.kt @@ -0,0 +1,51 @@ +package com.teamscale.jacoco.agent.options.sapnwdi + +import com.teamscale.jacoco.agent.options.AgentOptionParseException +import java.util.* + +/** + * An SAP application that is identified by a [.markerClass] and refers to a corresponding Teamscale project. + */ +data class SapNwdiApplication( + /** A fully qualified class name that is used to match a jar file to this application. */ + @JvmField val markerClass: String, + /** The teamscale project to which coverage should be uploaded. */ + @JvmField val teamscaleProject: String +) { + companion object { + /** Parses an application definition string e.g. "com.package.MyClass:projectId;com.company.Main:project". */ + @JvmStatic + @Throws(AgentOptionParseException::class) + fun parseApplications(applications: String): List { + if (applications.isBlank()) { + throw AgentOptionParseException("Application definition is expected not to be empty.") + } + val markerClassAndProjectPairs = applications.split(";") + + return markerClassAndProjectPairs.map { pair -> + if (pair.isBlank()) { + throw AgentOptionParseException("Application definition is expected not to be empty.") + } + + val parts = pair.split(":").dropLastWhile { it.isEmpty() } + if (parts.size != 2) { + throw AgentOptionParseException( + "Application definition $pair is expected to contain a marker class and project separated by a colon." + ) + } + + val markerClass = parts[0].trim() + if (markerClass.isEmpty()) { + throw AgentOptionParseException("Marker class is not given for $pair!") + } + + val teamscaleProject = parts[1].trim() + if (teamscaleProject.isEmpty()) { + throw AgentOptionParseException("Teamscale project is not given for $pair!") + } + + SapNwdiApplication(markerClass, teamscaleProject) + } + } + } +} diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/testimpact/CoverageToDiskStrategy.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/testimpact/CoverageToDiskStrategy.kt new file mode 100644 index 000000000..2ac924d37 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/testimpact/CoverageToDiskStrategy.kt @@ -0,0 +1,21 @@ +package com.teamscale.jacoco.agent.testimpact + +import com.teamscale.client.FileSystemUtils.writeFileUTF8 +import com.teamscale.jacoco.agent.JacocoRuntimeController +import com.teamscale.jacoco.agent.options.AgentOptions +import com.teamscale.report.testwise.jacoco.JaCoCoTestwiseReportGenerator +import java.io.IOException + +/** + * Strategy for appending coverage into one json test-wise coverage file with one session per test. + */ +class CoverageToDiskStrategy( + controller: JacocoRuntimeController, agentOptions: AgentOptions, + reportGenerator: JaCoCoTestwiseReportGenerator +) : CoverageToJsonStrategyBase(controller, agentOptions, reportGenerator) { + @Throws(IOException::class) + override fun handleTestwiseCoverageJsonReady(json: String) { + val reportFile = agentOptions.createNewFileInPartitionOutputDirectory("testwise-coverage", "json") + writeFileUTF8(reportFile, json) + } +} diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/testimpact/CoverageToExecFileStrategy.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/testimpact/CoverageToExecFileStrategy.kt new file mode 100644 index 000000000..6eed9d8ac --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/testimpact/CoverageToExecFileStrategy.kt @@ -0,0 +1,44 @@ +package com.teamscale.jacoco.agent.testimpact + +import com.teamscale.jacoco.agent.JacocoRuntimeController +import com.teamscale.jacoco.agent.JacocoRuntimeController.DumpException +import com.teamscale.jacoco.agent.logging.LoggingUtils.getLogger +import com.teamscale.jacoco.agent.options.AgentOptions +import com.teamscale.report.testwise.jacoco.cache.CoverageGenerationException +import com.teamscale.report.testwise.model.TestExecution +import com.teamscale.report.testwise.model.TestInfo +import java.io.IOException + +/** + * Strategy for appending coverage into one exec file with one session per test. Execution data will be stored in a json + * file side-by-side with the exec file. Test executions are also appended into a single file. + */ +class CoverageToExecFileStrategy( + controller: JacocoRuntimeController, agentOptions: AgentOptions, + /** Helper for writing test executions to disk. */ + private val testExecutionWriter: TestExecutionWriter? +) : TestEventHandlerStrategyBase(agentOptions, controller) { + private val logger = getLogger(this) + + @Throws(DumpException::class, CoverageGenerationException::class) + override fun testEnd( + test: String, + testExecution: TestExecution? + ): TestInfo? { + logger.debug("Test {} ended with execution {}. Writing exec file and test execution", test, testExecution) + super.testEnd(test, testExecution) + controller.dump() + // Ensures that the coverage collected between the last test and the JVM shutdown + // is not considered a test with the same name as the last test + controller.resetSessionId() + if (testExecution != null) { + try { + testExecutionWriter?.append(testExecution) + logger.debug("Successfully wrote test execution for {}", test) + } catch (e: IOException) { + logger.error("Failed to store test execution: {}", e.message, e) + } + } + return null + } +} diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/testimpact/CoverageToJsonStrategyBase.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/testimpact/CoverageToJsonStrategyBase.kt new file mode 100644 index 000000000..1cf01f980 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/testimpact/CoverageToJsonStrategyBase.kt @@ -0,0 +1,140 @@ +package com.teamscale.jacoco.agent.testimpact + +import com.teamscale.client.ClusteredTestDetails +import com.teamscale.client.JsonUtils.serializeToJson +import com.teamscale.client.PrioritizableTestCluster +import com.teamscale.jacoco.agent.JacocoRuntimeController +import com.teamscale.jacoco.agent.JacocoRuntimeController.DumpException +import com.teamscale.jacoco.agent.logging.LoggingUtils.getLogger +import com.teamscale.jacoco.agent.options.AgentOptions +import com.teamscale.report.testwise.jacoco.JaCoCoTestwiseReportGenerator +import com.teamscale.report.testwise.jacoco.cache.CoverageGenerationException +import com.teamscale.report.testwise.model.TestExecution +import com.teamscale.report.testwise.model.TestInfo +import com.teamscale.report.testwise.model.builder.TestCoverageBuilder +import com.teamscale.report.testwise.model.builder.TestwiseCoverageReportBuilder +import org.slf4j.Logger +import java.io.File +import java.io.IOException +import java.util.stream.Collectors + +/** + * Base for strategies that produce testwise coverage information in JSON and store or send this data further. + */ +abstract class CoverageToJsonStrategyBase( + controller: JacocoRuntimeController, + agentOptions: AgentOptions, + private val reportGenerator: JaCoCoTestwiseReportGenerator +) : TestEventHandlerStrategyBase(agentOptions, controller) { + + @JvmField + protected val logger: Logger = getLogger(this) + + /** + * The path to the exec file into which the coverage of the current test run is appended to. Will be null if there + * is no file for the current test run yet. + */ + private var testExecFile: File? = null + private val testExecutions = mutableListOf() + private var availableTests = mutableListOf() + + @Throws(IOException::class) + override fun testRunStart( + availableTests: List?, + includeNonImpactedTests: Boolean, + includeAddedTests: Boolean, includeFailedAndSkipped: Boolean, + baseline: String?, baselineRevision: String? + ): List? { + if (availableTests != null) { + this.availableTests = ArrayList(availableTests) + } + return super.testRunStart( + this.availableTests, includeNonImpactedTests, includeAddedTests, + includeFailedAndSkipped, baseline, baselineRevision + ) + } + + override fun testStart(test: String) { + super.testStart(test) + if (availableTests.none { it.uniformPath == test }) { + // ensure that we can at least generate a report for the tests that were actually run, + // even if the caller did not provide a list of tests up-front in testRunStart + availableTests.add(ClusteredTestDetails(test, test, null, null)) + } + } + + @Throws(DumpException::class, CoverageGenerationException::class) + override fun testEnd( + test: String, + testExecution: TestExecution? + ): TestInfo? { + super.testEnd(test, testExecution) + if (testExecution != null) { + testExecutions.add(testExecution) + } + + try { + if (testExecFile == null) { + testExecFile = agentOptions.createNewFileInOutputDirectory("coverage", "exec") + testExecFile?.deleteOnExit() + } + controller.dumpToFileAndReset(testExecFile!!) + } catch (e: IOException) { + throw DumpException("Failed to write coverage to disk into $testExecFile!", e) + } + return null + } + + @Throws(IOException::class, CoverageGenerationException::class) + override fun testRunEnd(partial: Boolean) { + if (testExecFile == null) { + logger.warn("Tried to end a test run that contained no tests!") + clearTestRun() + return + } + + handleTestwiseCoverageJsonReady(createTestwiseCoverageReport(partial)) + } + + /** + * Hook that is invoked when the JSON is ready for processed further. + */ + @Throws(IOException::class) + protected abstract fun handleTestwiseCoverageJsonReady(json: String) + + /** + * Creates a testwise coverage report from the coverage collected in [testExecFile] and the test execution + * information in [testExecutions]. + */ + @Throws(IOException::class, CoverageGenerationException::class) + private fun createTestwiseCoverageReport(partial: Boolean): String { + val executionUniformPaths = testExecutions.map { it.uniformPath } + + logger.debug( + "Creating testwise coverage from available tests `{}`, test executions `{}`, exec file and partial {}", + availableTests.map { it.uniformPath }, + executionUniformPaths, partial) + reportGenerator.updateClassDirCache() + val testwiseCoverage = reportGenerator.convert(testExecFile!!) + logger.debug( + "Created testwise coverage report (containing coverage for tests `{}`)", + testwiseCoverage.tests.values.map(TestCoverageBuilder::uniformPath) + ) + + val report = TestwiseCoverageReportBuilder.createFrom( + availableTests, + testwiseCoverage.tests.values, testExecutions, partial + ) + + testExecFile?.delete() + testExecFile = null + clearTestRun() + + return report.serializeToJson() + } + + private fun clearTestRun() { + availableTests.clear() + testExecutions.clear() + } +} diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/testimpact/CoverageToTeamscaleStrategy.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/testimpact/CoverageToTeamscaleStrategy.kt new file mode 100644 index 000000000..981cabab4 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/testimpact/CoverageToTeamscaleStrategy.kt @@ -0,0 +1,38 @@ +package com.teamscale.jacoco.agent.testimpact + +import com.teamscale.client.EReportFormat +import com.teamscale.client.FileSystemUtils.writeFileUTF8 +import com.teamscale.jacoco.agent.JacocoRuntimeController +import com.teamscale.jacoco.agent.options.AgentOptions +import com.teamscale.report.testwise.jacoco.JaCoCoTestwiseReportGenerator +import java.io.IOException + +/** + * Strategy that records test-wise coverage and uploads the resulting report to Teamscale. Also handles the + * [.testRunStart] event by retrieving tests to run from + * Teamscale. + */ +class CoverageToTeamscaleStrategy( + controller: JacocoRuntimeController, agentOptions: AgentOptions, + reportGenerator: JaCoCoTestwiseReportGenerator +) : CoverageToJsonStrategyBase(controller, agentOptions, reportGenerator) { + @Throws(IOException::class) + override fun handleTestwiseCoverageJsonReady(json: String) { + try { + teamscaleClient?.uploadReport( + EReportFormat.TESTWISE_COVERAGE, json, + agentOptions.teamscaleServer.commit, + agentOptions.teamscaleServer.revision, + agentOptions.teamscaleServer.repository, + agentOptions.teamscaleServer.partition!!, + agentOptions.teamscaleServer.message!! + ) + } catch (e: IOException) { + val reportFile = agentOptions.createNewFileInOutputDirectory("testwise-coverage", "json") + writeFileUTF8(reportFile, json) + val errorMessage = "Failed to upload coverage to Teamscale! Report is stored in $reportFile!" + logger.error(errorMessage, e) + throw IOException(errorMessage, e) + } + } +} diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/testimpact/CoverageViaHttpStrategy.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/testimpact/CoverageViaHttpStrategy.kt new file mode 100644 index 000000000..fbec8525c --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/testimpact/CoverageViaHttpStrategy.kt @@ -0,0 +1,41 @@ +package com.teamscale.jacoco.agent.testimpact + +import com.teamscale.jacoco.agent.JacocoRuntimeController +import com.teamscale.jacoco.agent.JacocoRuntimeController.DumpException +import com.teamscale.jacoco.agent.logging.LoggingUtils.getLogger +import com.teamscale.jacoco.agent.options.AgentOptions +import com.teamscale.report.testwise.jacoco.JaCoCoTestwiseReportGenerator +import com.teamscale.report.testwise.jacoco.cache.CoverageGenerationException +import com.teamscale.report.testwise.model.TestExecution +import com.teamscale.report.testwise.model.TestInfo +import com.teamscale.report.testwise.model.builder.TestCoverageBuilder +import com.teamscale.report.testwise.model.builder.TestInfoBuilder +import java.util.* + +/** + * Strategy which directly converts the collected coverage into a JSON object in place and returns the result to the + * caller as response to the http request. If a test execution is given it is merged into the representation and + * returned together with the coverage. + */ +class CoverageViaHttpStrategy( + controller: JacocoRuntimeController, agentOptions: AgentOptions, + private val reportGenerator: JaCoCoTestwiseReportGenerator +) : TestEventHandlerStrategyBase(agentOptions, controller) { + private val logger = getLogger(this) + + @Throws(DumpException::class, CoverageGenerationException::class) + override fun testEnd(test: String, testExecution: TestExecution?): TestInfo { + super.testEnd(test, testExecution) + + val builder = TestInfoBuilder(test) + val dump = controller.dumpAndReset() + reportGenerator.updateClassDirCache() + reportGenerator.convert(dump)?.let { builder.setCoverage(it) } + if (testExecution != null) { + builder.setExecution(testExecution) + } + val testInfo = builder.build() + logger.debug("Generated test info {}", testInfo) + return testInfo + } +} diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/testimpact/TestEventHandlerStrategyBase.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/testimpact/TestEventHandlerStrategyBase.kt new file mode 100644 index 000000000..dde2dc676 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/testimpact/TestEventHandlerStrategyBase.kt @@ -0,0 +1,165 @@ +package com.teamscale.jacoco.agent.testimpact + +import com.teamscale.client.ClusteredTestDetails +import com.teamscale.client.HttpUtils.getErrorBodyStringSafe +import com.teamscale.client.PrioritizableTestCluster +import com.teamscale.client.TeamscaleClient +import com.teamscale.client.TestWithClusterId +import com.teamscale.jacoco.agent.JacocoRuntimeController +import com.teamscale.jacoco.agent.JacocoRuntimeController.DumpException +import com.teamscale.jacoco.agent.logging.LoggingUtils.getLogger +import com.teamscale.jacoco.agent.options.AgentOptions +import com.teamscale.jacoco.agent.upload.teamscale.TeamscaleConfig +import com.teamscale.report.testwise.jacoco.cache.CoverageGenerationException +import com.teamscale.report.testwise.model.TestExecution +import com.teamscale.report.testwise.model.TestInfo +import retrofit2.Response +import java.io.IOException +import java.util.stream.Collectors + +/** Base class for strategies to handle test events. */ +abstract class TestEventHandlerStrategyBase protected constructor( + /** The options the user has configured for the agent. */ + @JvmField protected val agentOptions: AgentOptions, + /** Controls the JaCoCo runtime. */ + @JvmField protected val controller: JacocoRuntimeController +) { + private val logger = getLogger(this) + + /** The timestamp at which the /test/start endpoint has been called last time. */ + private var startTimestamp: Long = -1 + + /** May be null if the user did not configure Teamscale. */ + @JvmField + protected val teamscaleClient = agentOptions.createTeamscaleClient(true) + + /** Called when test test with the given name is about to start. */ + open fun testStart(test: String) { + logger.debug("Test {} started", test) + // Reset coverage so that we only record coverage that belongs to this particular test case. + controller.reset() + controller.sessionId = test + startTimestamp = System.currentTimeMillis() + } + + /** + * Called when the test with the given name finished. + * + * @param test Uniform path of the test + * @param testExecution A test execution object holding the test result and error message. May be null if none is + * given in the request. + * @return The body of the response. `null` indicates "204 No content". Non-null results will be treated + * as a json response. + */ + @Throws(DumpException::class, CoverageGenerationException::class) + open fun testEnd( + test: String, + testExecution: TestExecution? + ): TestInfo? { + if (testExecution != null) { + testExecution.uniformPath = test + if (startTimestamp != -1L) { + val endTimestamp = System.currentTimeMillis() + @Suppress("DEPRECATION") + testExecution.durationMillis = endTimestamp - startTimestamp + } + } + logger.debug("Test {} ended with test execution {}", test, testExecution) + return null + } + + /** + * Retrieves impacted tests from Teamscale, if a [.teamscaleClient] has been configured. + * + * @param availableTests List of all available tests that could be run or null if the user does not want to + * provide one. + * @param includeNonImpactedTests If this is true, only performs prioritization, no selection. + * @param baseline Optional baseline for the considered changes. + * @throws IOException if the request to Teamscale failed. + * @throws UnsupportedOperationException if the user did not properly configure the [.teamscaleClient]. + */ + @Throws(IOException::class) + open fun testRunStart( + availableTests: List?, + includeNonImpactedTests: Boolean, + includeAddedTests: Boolean, includeFailedAndSkipped: Boolean, + baseline: String?, baselineRevision: String? + ): List? { + var availableTestCount = 0 + var availableTestsWithClusterId: List? = null + if (availableTests != null) { + availableTestCount = availableTests.size + availableTestsWithClusterId = availableTests.map { availableTest -> + TestWithClusterId.fromClusteredTestDetails( + availableTest, + partition + ) + } + } + logger.debug( + "Test run started with {} available tests. baseline = {}, includeNonImpactedTests = {}", + availableTestCount, baseline, includeNonImpactedTests + ) + validateConfiguration() + + val response = teamscaleClient!!.getImpactedTests( + availableTestsWithClusterId, baseline, baselineRevision, + agentOptions.teamscaleServer.commit, + agentOptions.teamscaleServer.revision, + agentOptions.teamscaleServer.repository, + mutableListOf(agentOptions.teamscaleServer.partition!!), + includeNonImpactedTests, includeAddedTests, includeFailedAndSkipped + ) + if (response.isSuccessful) { + val prioritizableTestClusters = response.body() + logger.debug("Teamscale suggested these tests: {}", prioritizableTestClusters) + return prioritizableTestClusters + } else { + val responseBody = getErrorBodyStringSafe(response) + throw IOException( + "Request to Teamscale to get impacted tests failed with HTTP status ${response.code()} ${response.message()}. Response body: $responseBody" + ) + } + } + + /** + * Returns the partition defined in the agent options. Asserts that the partition is defined. + */ + private val partition: String + get() = agentOptions.teamscaleServer.partition ?: throw UnsupportedOperationException( + "You must provide a partition via the agent's '${TeamscaleConfig.TEAMSCALE_PARTITION_OPTION}' option or using the /partition REST endpoint." + ) + + private fun validateConfiguration() { + if (teamscaleClient == null) { + throw UnsupportedOperationException( + "You did not configure a connection to Teamscale in the agent." + + " Thus, you cannot use the agent to retrieve impacted tests via the testrun/start REST endpoint." + + " Please use the 'teamscale-' agent parameters to configure a Teamscale connection." + ) + } + if (!agentOptions.teamscaleServer.hasCommitOrRevision()) { + throw UnsupportedOperationException( + "You must provide a revision or commit via the agent's '" + TeamscaleConfig.TEAMSCALE_REVISION_OPTION + "', '" + + TeamscaleConfig.TEAMSCALE_REVISION_MANIFEST_JAR_OPTION + "', '" + TeamscaleConfig.TEAMSCALE_COMMIT_OPTION + + "', '" + TeamscaleConfig.TEAMSCALE_COMMIT_MANIFEST_JAR_OPTION + "' or '" + + AgentOptions.GIT_PROPERTIES_JAR_OPTION + "' option." + + " Auto-detecting the git.properties does not work since we need the commit before any code" + + " has been profiled in order to obtain the prioritized test cases from the TIA." + ) + } + } + + /** + * Signals that the test run has ended. Strategies that support this can upload a report via the + * [.teamscaleClient] here. + */ + @Throws(IOException::class, CoverageGenerationException::class) + open fun testRunEnd(partial: Boolean) { + throw UnsupportedOperationException( + "You configured the agent in a mode that does not support uploading " + + "reports to Teamscale. Please configure 'tia-mode=teamscale-upload' or simply don't call" + + "POST /testrun/end." + ) + } +} diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/testimpact/TestExecutionWriter.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/testimpact/TestExecutionWriter.kt new file mode 100644 index 000000000..91acf1d92 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/testimpact/TestExecutionWriter.kt @@ -0,0 +1,44 @@ +package com.teamscale.jacoco.agent.testimpact + +import com.teamscale.client.JsonUtils.serializeToJson +import com.teamscale.jacoco.agent.logging.LoggingUtils.getLogger +import com.teamscale.report.testwise.model.TestExecution +import java.io.File +import java.io.IOException +import java.io.RandomAccessFile +import java.nio.charset.StandardCharsets + +/** + * Helper class for writing a list of test executions to a file. This class ensures that we never have to hold all test + * executions in memory but rather incrementally append to the output file. This ensures that we don't use unnecessary + * amounts of memory during profiling. + */ +class TestExecutionWriter(private val testExecutionFile: File) { + private val logger = getLogger(this) + + private var hasWrittenAtLeastOneExecution = false + + init { + logger.debug("Writing test executions to {}", testExecutionFile) + } + + /** Appends the given [TestExecution] to the test execution list file. */ + @Synchronized + @Throws(IOException::class) + fun append(testExecution: TestExecution) { + val json = testExecution.serializeToJson() + + RandomAccessFile(testExecutionFile, "rwd").use { file -> + var textToWrite = "$json]" + if (hasWrittenAtLeastOneExecution) { + textToWrite = ",$textToWrite" + // overwrite the trailing "]" + file.seek(file.length() - 1) + } else { + textToWrite = "[$textToWrite" + } + file.write(textToWrite.toByteArray(StandardCharsets.UTF_8)) + } + hasWrittenAtLeastOneExecution = true + } +} diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/testimpact/TestwiseCoverageAgent.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/testimpact/TestwiseCoverageAgent.kt new file mode 100644 index 000000000..3da3ee969 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/testimpact/TestwiseCoverageAgent.kt @@ -0,0 +1,69 @@ +package com.teamscale.jacoco.agent.testimpact + +import com.teamscale.jacoco.agent.AgentBase +import com.teamscale.jacoco.agent.GenericExceptionMapper +import com.teamscale.jacoco.agent.logging.LoggingUtils +import com.teamscale.jacoco.agent.logging.LoggingUtils.wrap +import com.teamscale.jacoco.agent.options.AgentOptions +import com.teamscale.jacoco.agent.options.ETestwiseCoverageMode +import com.teamscale.jacoco.agent.testimpact.TestwiseCoverageResource.Companion.setAgent +import com.teamscale.report.testwise.jacoco.JaCoCoTestwiseReportGenerator +import org.glassfish.jersey.server.ResourceConfig +import org.glassfish.jersey.server.ServerProperties +import java.io.IOException +import java.lang.Boolean +import kotlin.Throws + +/** + * A wrapper around the JaCoCo Java agent that starts a HTTP server and listens for test events. + */ +class TestwiseCoverageAgent( + options: AgentOptions, + testExecutionWriter: TestExecutionWriter?, + reportGenerator: JaCoCoTestwiseReportGenerator +) : AgentBase(options) { + /** + * The test event strategy handler. + */ + val testEventHandler = when (options.testwiseCoverageMode) { + ETestwiseCoverageMode.TEAMSCALE_UPLOAD -> CoverageToTeamscaleStrategy(controller, options, reportGenerator) + ETestwiseCoverageMode.DISK -> CoverageToDiskStrategy(controller, options, reportGenerator) + ETestwiseCoverageMode.HTTP -> CoverageViaHttpStrategy(controller, options, reportGenerator) + else -> CoverageToExecFileStrategy(controller, options, testExecutionWriter) + } + + init { + // Set to empty to not end up with a default session in case no tests are executed, + // which in turn causes a warning because we didn't write a corresponding test detail + controller.sessionId = "" + } + + override fun initResourceConfig(): ResourceConfig? { + val resourceConfig = ResourceConfig() + resourceConfig.property(ServerProperties.WADL_FEATURE_DISABLE, Boolean.TRUE.toString()) + setAgent(this) + return resourceConfig.register(TestwiseCoverageResource::class.java) + .register(GenericExceptionMapper::class.java) + } + + override fun dumpReport() { + // Dumping via the API is not supported in testwise mode. Ending the test run dumps automatically + } + + companion object { + /** Creates a [TestwiseCoverageAgent] based on the given options. */ + @Throws(IOException::class) + fun create(agentOptions: AgentOptions): TestwiseCoverageAgent { + val logger = LoggingUtils.getLogger(JaCoCoTestwiseReportGenerator::class.java) + val reportGenerator = JaCoCoTestwiseReportGenerator( + agentOptions.classDirectoriesOrZips, agentOptions.locationIncludeFilter, + agentOptions.duplicateClassFileBehavior, wrap(logger) + ) + return TestwiseCoverageAgent( + agentOptions, + TestExecutionWriter(agentOptions.createNewFileInOutputDirectory("test-execution", "json")), + reportGenerator + ) + } + } +} diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/testimpact/TestwiseCoverageResource.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/testimpact/TestwiseCoverageResource.kt new file mode 100644 index 000000000..ec99c998b --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/testimpact/TestwiseCoverageResource.kt @@ -0,0 +1,99 @@ +package com.teamscale.jacoco.agent.testimpact + +import com.teamscale.client.ClusteredTestDetails +import com.teamscale.client.PrioritizableTestCluster +import com.teamscale.jacoco.agent.JacocoRuntimeController.DumpException +import com.teamscale.jacoco.agent.ResourceBase +import com.teamscale.report.testwise.jacoco.cache.CoverageGenerationException +import com.teamscale.report.testwise.model.TestExecution +import com.teamscale.report.testwise.model.TestInfo +import java.io.IOException +import javax.ws.rs.* +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 + * [TestwiseCoverageAgent]. + */ +@Path("/") +class TestwiseCoverageResource : ResourceBase() { + @get:Path("/test") + @get:GET + val test: String? + /** Returns the session ID of the current test. */ + get() = testwiseCoverageAgent?.controller?.sessionId + + /** Handles the start of a new test case by setting the session ID. */ + @POST + @Path("/test/start/{$TEST_ID_PARAMETER}") + fun handleTestStart(@PathParam(TEST_ID_PARAMETER) testId: String?): Response? { + if (testId.isNullOrEmpty()) handleBadRequest("Test name is missing!") + + logger.debug("Start test {}", testId) + + testwiseCoverageAgent?.testEventHandler?.testStart(testId!!) + return Response.noContent().build() + } + + /** Handles the end of a test case by resetting the session ID. */ + @POST + @Produces(MediaType.APPLICATION_JSON) + @Path("/test/end/{$TEST_ID_PARAMETER}") + @Throws(DumpException::class, CoverageGenerationException::class) + fun handleTestEnd( + @PathParam(TEST_ID_PARAMETER) testId: String?, + testExecution: TestExecution? + ): TestInfo? { + if (testId.isNullOrEmpty()) handleBadRequest("Test name is missing!") + + logger.debug("End test {}", testId) + + return testwiseCoverageAgent?.testEventHandler?.testEnd(testId!!, testExecution) + } + + /** Handles the start of a new testrun. */ + @POST + @Produces(MediaType.APPLICATION_JSON) + @Path("/testrun/start") + @Throws(IOException::class) + fun handleTestRunStart( + @QueryParam("include-non-impacted") includeNonImpactedTests: Boolean, + @QueryParam("include-added-tests") includeAddedTests: Boolean, + @QueryParam("include-failed-and-skipped") includeFailedAndSkipped: Boolean, + @QueryParam("baseline") baseline: String?, + @QueryParam("baseline-revision") baselineRevision: String?, + availableTests: List? + ) = testwiseCoverageAgent?.testEventHandler?.testRunStart( + availableTests, + includeNonImpactedTests, includeAddedTests, + includeFailedAndSkipped, baseline, baselineRevision + ) + + /** Handles the end of a new testrun. */ + @POST + @Path("/testrun/end") + @Throws(IOException::class, CoverageGenerationException::class) + fun handleTestRunEnd( + @DefaultValue("false") @QueryParam("partial") partial: Boolean + ): Response? { + testwiseCoverageAgent?.testEventHandler?.testRunEnd(partial) + return Response.noContent().build() + } + + companion object { + /** Path parameter placeholder used in the HTTP requests. */ + private const val TEST_ID_PARAMETER = "testId" + + private var testwiseCoverageAgent: TestwiseCoverageAgent? = null + + /** + * Static setter to inject the [TestwiseCoverageAgent] to the resource. + */ + @JvmStatic + fun setAgent(agent: TestwiseCoverageAgent) { + testwiseCoverageAgent = agent + agentBase = agent + } + } +} 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..19bfcee91 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/HttpZipUploaderBase.kt @@ -0,0 +1,144 @@ +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.report.jacoco.CoverageFile +import okhttp3.HttpUrl +import okhttp3.OkHttpClient +import okhttp3.ResponseBody +import org.slf4j.Logger +import retrofit2.Response +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.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: List, + /** 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) + + additionalMetaDataFiles.forEach { additionalFile -> + 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..45a076fa5 --- /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: List, + 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..cc2b68fd0 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/artifactory/IArtifactoryUploadApi.kt @@ -0,0 +1,30 @@ +package com.teamscale.jacoco.agent.upload.artifactory + +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.asRequestBody +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 = data.asRequestBody("application/zip".toMediaTypeOrNull()) + return upload(path, body).execute() + } +} diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/azure/AzureFileStorageConfig.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/azure/AzureFileStorageConfig.kt new file mode 100644 index 000000000..789a64bb8 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/azure/AzureFileStorageConfig.kt @@ -0,0 +1,51 @@ +package com.teamscale.jacoco.agent.upload.azure + +import com.teamscale.jacoco.agent.options.AgentOptionParseException +import com.teamscale.jacoco.agent.options.AgentOptionsParser +import okhttp3.HttpUrl + +/** Config necessary to upload files to an azure file storage. */ +class AzureFileStorageConfig { + /** The URL to the azure file storage */ + @JvmField + var url: HttpUrl? = null + + /** The access key of the azure file storage */ + @JvmField + var accessKey: String? = null + + /** Checks if none of the required fields is null. */ + fun hasAllRequiredFieldsSet(): Boolean { + return url != null && accessKey != null + } + + /** Checks if all required fields are null. */ + fun hasAllRequiredFieldsNull(): Boolean { + return url == null && accessKey == null + } + + companion object { + /** + * Handles all command-line options prefixed with 'azure-' + * @return true if it has successfully processed the given option. + */ + @JvmStatic + @Throws(AgentOptionParseException::class) + fun handleAzureFileStorageOptions( + azureFileStorageConfig: AzureFileStorageConfig, key: String, + value: String + ): Boolean { + when (key) { + "azure-url" -> { + azureFileStorageConfig.url = AgentOptionsParser.parseUrl(key, value) + return true + } + "azure-key" -> { + azureFileStorageConfig.accessKey = value + return true + } + else -> return false + } + } + } +} diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/azure/AzureFileStorageHttpUtils.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/azure/AzureFileStorageHttpUtils.kt new file mode 100644 index 000000000..580abbdc9 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/azure/AzureFileStorageHttpUtils.kt @@ -0,0 +1,116 @@ +package com.teamscale.jacoco.agent.upload.azure + +import com.teamscale.jacoco.agent.upload.UploaderException +import com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.CONTENT_ENCODING +import com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.CONTENT_LANGUAGE +import com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.CONTENT_LENGTH +import com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.CONTENT_MD_5 +import com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.CONTENT_TYPE +import com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.DATE +import com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.IF_MATCH +import com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.IF_MODIFIED_SINCE +import com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.IF_NONE_MATCH +import com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.IF_UNMODIFIED_SINCE +import com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.RANGE +import com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.X_MS_DATE +import com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.X_MS_VERSION +import java.io.UnsupportedEncodingException +import java.security.InvalidKeyException +import java.security.NoSuchAlgorithmException +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.* +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +/** Utils class for communicating with an azure file storage. */ /* package */ +internal object AzureFileStorageHttpUtils { + /** Version of the azure file storage. Must be in every request */ + private const val VERSION = "2018-03-28" + + /** Formatting pattern for every date in a request */ + private val FORMAT: DateTimeFormatter = DateTimeFormatter.ofPattern("E, dd MMM y HH:mm:ss z").withZone( + ZoneId.of("GMT") + ) + + /** Creates the string that must be signed as the authorization for the request. */ + private fun createSignString( + httpMethod: EHttpMethod, headers: Map, account: String?, + path: String?, queryParameters: Map + ): String { + require(headers.keys.containsAll(listOf(X_MS_DATE, X_MS_VERSION))) { + "Headers for the azure request cannot be empty! At least 'x-ms-version' and 'x-ms-date' must be set" + } + + val keys = listOf( + CONTENT_ENCODING, CONTENT_LANGUAGE, CONTENT_LENGTH, CONTENT_MD_5, CONTENT_TYPE, DATE, IF_MODIFIED_SINCE, + IF_MATCH, IF_NONE_MATCH, IF_UNMODIFIED_SINCE, RANGE + ).map { headers.getOrDefault(it, "") } + + val xmsHeader = headers.filter { it.key.startsWith("x-ms") } + return listOf( httpMethod.toString(), + keys, xmsHeader.createCanonicalizedString(), + createCanonicalizedResources(account, path, queryParameters) + ).joinToString() + } + + /** Creates the string for the canonicalized resources. */ + private fun createCanonicalizedResources( + account: String?, + path: String?, + options: Map + ): String { + var canonicalizedResources = "/$account$path" + + if (options.isNotEmpty()) { + canonicalizedResources += "\n" + options.createCanonicalizedString() + } + + return canonicalizedResources + } + + /** Creates a string with a map where each key-value pair is in a newline separated by a colon. */ + private fun Map.createCanonicalizedString() = + toSortedMap().map { (key, value) -> "$key:${value}" }.joinToString("\n") + + /** Creates the string which is needed for the authorization of an azure file storage request. */ /* package */ + @JvmStatic + @Throws(UploaderException::class) + fun getAuthorizationString( + method: EHttpMethod, account: String, key: String?, path: String?, + headers: Map, queryParameters: Map + ): String { + val stringToSign = createSignString(method, headers, account, path, queryParameters) + + try { + val mac = Mac.getInstance("HmacSHA256") + mac.init(SecretKeySpec(Base64.getDecoder().decode(key), "HmacSHA256")) + val authKey = String(Base64.getEncoder().encode(mac.doFinal(stringToSign.toByteArray(charset("UTF-8"))))) + return "SharedKey $account:$authKey" + } catch (e: NoSuchAlgorithmException) { + throw UploaderException("Something is really wrong...", e) + } catch (e: UnsupportedEncodingException) { + throw UploaderException("Something is really wrong...", e) + } catch (e: InvalidKeyException) { + throw UploaderException("The given access key is malformed: $key", e) + } catch (e: IllegalArgumentException) { + throw UploaderException("The given access key is malformed: $key", e) + } + } + + @JvmStatic + val baseHeaders: Map + /** Returns the list of headers which must be present at every request */ + get() = mapOf( + X_MS_VERSION to VERSION, + X_MS_DATE to FORMAT.format(LocalDateTime.now()) + ) + + /** Simple enum for all available HTTP methods. */ + enum class EHttpMethod { + PUT, + HEAD + } +} + diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/azure/AzureFileStorageUploader.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/azure/AzureFileStorageUploader.kt new file mode 100644 index 000000000..3257023c3 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/azure/AzureFileStorageUploader.kt @@ -0,0 +1,264 @@ +package com.teamscale.jacoco.agent.upload.azure + +import com.teamscale.client.EReportFormat +import com.teamscale.client.FileSystemUtils.normalizeSeparators +import com.teamscale.client.FileSystemUtils.replaceFilePathFilenameWith +import com.teamscale.jacoco.agent.upload.HttpZipUploaderBase +import com.teamscale.jacoco.agent.upload.IUploadRetry +import com.teamscale.jacoco.agent.upload.UploaderException +import com.teamscale.jacoco.agent.upload.azure.AzureFileStorageHttpUtils.EHttpMethod +import com.teamscale.jacoco.agent.upload.azure.AzureFileStorageHttpUtils.baseHeaders +import com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.AUTHORIZATION +import com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.CONTENT_LENGTH +import com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.CONTENT_TYPE +import com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.X_MS_CONTENT_LENGTH +import com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.X_MS_RANGE +import com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.X_MS_TYPE +import com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.X_MS_WRITE +import com.teamscale.jacoco.agent.upload.teamscale.TeamscaleUploader +import com.teamscale.report.jacoco.CoverageFile +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.RequestBody.Companion.asRequestBody +import okhttp3.RequestBody.Companion.create +import okhttp3.ResponseBody +import retrofit2.Response +import java.io.File +import java.io.IOException +import java.nio.file.Path +import java.util.* +import java.util.regex.Pattern +import kotlin.Throws +import kotlin.text.format +import kotlin.text.lowercase + +/** Uploads the coverage archive to a provided azure file storage. */ +class AzureFileStorageUploader( + config: AzureFileStorageConfig, + additionalMetaDataFiles: List +) : HttpZipUploaderBase( + config.url!!, + additionalMetaDataFiles, + IAzureUploadApi::class.java +), IUploadRetry { + /** The access key for the azure file storage */ + private var accessKey = config.accessKey + + /** The account for the azure file storage */ + private var account = getAccount() + + /** Constructor. */ + init { + validateUploadUrl() + } + + override fun markFileForUploadRetry(coverageFile: CoverageFile) { + val uploadMetadataFile = File( + replaceFilePathFilenameWith( + normalizeSeparators(coverageFile.toString()), + coverageFile.name + TeamscaleUploader.RETRY_UPLOAD_FILE_SUFFIX + ) + ) + try { + uploadMetadataFile.createNewFile() + } 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) { + // The azure uploader does not have any special reupload properties, so it will + // just use the normal upload instead. + upload(coverageFile) + } + + /** + * Extracts and returns the account of the provided azure file storage from the URL. + */ + @Throws(UploaderException::class) + private fun getAccount(): String { + val matcher = AZURE_FILE_STORAGE_HOST_PATTERN.matcher(uploadUrl.host) + if (matcher.matches()) { + return matcher.group(1) + } else { + throw UploaderException( + "URL is malformed. Must be in the format \"https://.file.core.windows.net//\", but was instead: $uploadUrl" + ) + } + } + + override fun describe() = "Uploading coverage to the Azure File Storage at $uploadUrl" + + @Throws(IOException::class, UploaderException::class) + override fun uploadCoverageZip(coverageFile: File): Response { + val fileName = createFileName() + if (checkFile(fileName).isSuccessful) { + logger.warn("The file $fileName does already exists at $uploadUrl") + } + + return createAndFillFile(coverageFile, fileName) + } + + /** + * Makes sure that the upload url is valid and that it exists on the file storage. If some directories do not + * exists, they will be created. + */ + @Throws(UploaderException::class) + private fun validateUploadUrl() { + val pathParts = uploadUrl.pathSegments + + if (pathParts.size < 2) { + throw UploaderException( + "${uploadUrl.toUrl().path} is too short for a file path on the storage. At least the share must be provided: https://.file.core.windows.net//" + ) + } + + try { + checkAndCreatePath(pathParts) + } catch (e: IOException) { + throw UploaderException( + "Checking the validity of ${uploadUrl.toUrl().path} failed. There is probably something wrong with the URL or a problem with the account/key: ", e + ) + } + } + + /** + * Checks the directory path in the azure url. Creates any missing directories. + */ + @Throws(IOException::class, UploaderException::class) + private fun checkAndCreatePath(pathParts: List) { + (2.. + val directoryPath = "/${pathParts.subList(0, i).joinToString("/")}/" + if (!checkDirectory(directoryPath).isSuccessful) { + val mkdirResponse = createDirectory(directoryPath) + if (!mkdirResponse.isSuccessful) { + throw UploaderException("Creation of path '/$directoryPath' was unsuccessful", mkdirResponse) + } + } + } + } + + /** Creates a file name for the zip-archive containing the coverage. */ + private fun createFileName() = "${EReportFormat.JACOCO.name.lowercase(Locale.getDefault())}-${System.currentTimeMillis()}.zip" + + /** Checks if the file with the given name exists */ + @Throws(IOException::class, UploaderException::class) + private fun checkFile(fileName: String): Response { + val filePath = "${uploadUrl.toUrl().path}$fileName" + + val headers = baseHeaders.toMutableMap() + val queryParameters = mutableMapOf() + + val auth = AzureFileStorageHttpUtils.getAuthorizationString( + EHttpMethod.HEAD, account, accessKey, filePath, headers, queryParameters + ) + + headers[AUTHORIZATION] = auth + return api.head(filePath, headers, queryParameters).execute() + } + + /** Checks if the directory given by the specified path does exist. */ + @Throws(IOException::class, UploaderException::class) + private fun checkDirectory(directoryPath: String): Response { + val headers = baseHeaders.toMutableMap() + + val queryParameters = mutableMapOf() + queryParameters["restype"] = "directory" + + val auth = AzureFileStorageHttpUtils.getAuthorizationString( + EHttpMethod.HEAD, account, accessKey, directoryPath, headers, queryParameters + ) + + headers[AUTHORIZATION] = auth + return api.head(directoryPath, headers, queryParameters).execute() + } + + /** + * Creates the directory specified by the given path. The path must contain the share where it should be created + * on. + */ + @Throws(IOException::class, UploaderException::class) + private fun createDirectory(directoryPath: String): Response { + val headers = baseHeaders.toMutableMap() + + val queryParameters = mutableMapOf() + queryParameters["restype"] = "directory" + + val auth = AzureFileStorageHttpUtils.getAuthorizationString( + EHttpMethod.PUT, account, accessKey, directoryPath, headers, queryParameters + ) + + headers[AUTHORIZATION] = auth + return api.put(directoryPath, headers, queryParameters).execute() + } + + /** Creates and fills a file with the given data and name. */ + @Throws(UploaderException::class, IOException::class) + private fun createAndFillFile(zipFile: File, fileName: String): Response { + val response = createFile(zipFile, fileName) + if (response.isSuccessful) { + return fillFile(zipFile, fileName) + } + logger.error("Creation of file '$fileName' was unsuccessful.") + return response + } + + /** + * Creates an empty file with the given name. The size is defined by the length of the given byte array. + */ + @Throws(IOException::class, UploaderException::class) + private fun createFile(zipFile: File, fileName: String): Response { + val filePath = "${uploadUrl.toUrl().path}$fileName" + + val headers = baseHeaders.toMutableMap() + headers[X_MS_CONTENT_LENGTH] = zipFile.length().toString() + headers[X_MS_TYPE] = "file" + + val queryParameters = mutableMapOf() + + val auth = AzureFileStorageHttpUtils.getAuthorizationString( + EHttpMethod.PUT, account, accessKey, filePath, headers, queryParameters + ) + + headers[AUTHORIZATION] = auth + return api.put(filePath, headers, queryParameters).execute() + } + + /** + * Fills the file defined by the name with the given data. Should be used with [.createFile], + * because the request only writes exactly the length of the given data, so the file should be exactly as big as the + * data, otherwise it will be partially filled or is not big enough. + */ + @Throws(IOException::class, UploaderException::class) + private fun fillFile(zipFile: File, fileName: String): Response { + val filePath = uploadUrl.toUrl().path + fileName + + val range = "bytes=0-${zipFile.length() - 1}" + val contentType = "application/octet-stream" + + val headers = baseHeaders.toMutableMap() + headers[X_MS_WRITE] = "update" + headers[X_MS_RANGE] = range + headers[CONTENT_LENGTH] = zipFile.length().toString() + headers[CONTENT_TYPE] = contentType + + val queryParameters = mutableMapOf() + queryParameters["comp"] = "range" + + val auth = AzureFileStorageHttpUtils.getAuthorizationString( + EHttpMethod.PUT, account, accessKey, filePath, headers, queryParameters + ) + headers[AUTHORIZATION] = auth + val content = zipFile.asRequestBody(contentType.toMediaTypeOrNull()) + return api.putData(filePath, headers, queryParameters, content).execute() + } + + companion object { + /** Pattern matches the host of a azure file storage */ + private val AZURE_FILE_STORAGE_HOST_PATTERN: Pattern = Pattern + .compile("^(\\w*)\\.file\\.core\\.windows\\.net$") + } +} diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/azure/AzureHttpHeader.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/azure/AzureHttpHeader.kt new file mode 100644 index 000000000..6833c960b --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/azure/AzureHttpHeader.kt @@ -0,0 +1,74 @@ +package com.teamscale.jacoco.agent.upload.azure + +/** Constants for the names of HTTP header used in a request to an Azure file storage. */ /* package */ +internal object AzureHttpHeader { + /** Same as [.CONTENT_LENGTH] */ /* package */ + const val X_MS_CONTENT_LENGTH: String = "x-ms-content-length" + + /** Same as [.DATE] */ /* package */ + const val X_MS_DATE: String = "x-ms-date" + + /** Same as [.RANGE] */ /* package */ + const val X_MS_RANGE: String = "x-ms-range" + + /** Type of filesystem object which the request is referring to. Can be 'file' or 'directory'. */ /* package */ + const val X_MS_TYPE: String = "x-ms-type" + + /** Version of the Azure file storage API */ /* package */ + const val X_MS_VERSION: String = "x-ms-version" + + /** + * Defines the type of write operation on a file. Can either be 'Update' or 'Clear'. For 'Update' the 'Range' and + * 'Content-Length' headers must match, for 'Clear', 'Content-Length' must be set to 0. + */ + /* package */ + const val X_MS_WRITE: String = "x-ms-write" + + /** + * Defines the authorization and must contain the account name and signature. Must be given in the following format: + * Authorization="[SharedKey|SharedKeyLite] :" + */ + /* package */ + const val AUTHORIZATION: String = "Authorization" + + /** Content-Encoding */ /* package */ + const val CONTENT_ENCODING: String = "Content-Encoding" + + /** Content-Language */ /* package */ + const val CONTENT_LANGUAGE: String = "Content-Language" + + /** Content-Length */ /* package */ + const val CONTENT_LENGTH: String = "Content-Length" + + /** The md5 hash of the sent content. */ /* package */ + const val CONTENT_MD_5: String = "Content-MD5" + + /** Content-Type */ /* package */ + const val CONTENT_TYPE: String = "Content-Type" + + /** The date time of the request */ /* package */ + const val DATE: String = "Date" + + /** Only send the response if the entity has not been modified since a specific time. */ /* package */ + const val IF_UNMODIFIED_SINCE: String = "If-Unmodified-Since" + + /** Allows a 304 Not Modified to be returned if content is unchanged. */ /* package */ + const val IF_MODIFIED_SINCE: String = "If-Modified-Since" + + /** + * Only perform the action if the client supplied entity matches the same entity on the server. This is mainly for + * methods like PUT to only update a resource if it has not been modified since the user last updated it. + */ + /* package */ + const val IF_MATCH: String = "If-Match" + + /** Allows a 304 Not Modified to be returned if content is unchanged */ /* package */ + const val IF_NONE_MATCH: String = "If-None-Match" + + /** + * Specifies the range of bytes to be written. Both the start and end of the range must be specified. Must be given + * in the following format: "bytes=startByte-endByte" + */ + /* package */ + const val RANGE: String = "Range" +} diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/azure/IAzureUploadApi.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/azure/IAzureUploadApi.kt new file mode 100644 index 000000000..cbf8707bc --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/azure/IAzureUploadApi.kt @@ -0,0 +1,34 @@ +package com.teamscale.jacoco.agent.upload.azure + +import okhttp3.RequestBody +import okhttp3.ResponseBody +import retrofit2.Call +import retrofit2.http.* + +/** [retrofit2.Retrofit] API specification for the [AzureFileStorageUploader]. */ +interface IAzureUploadApi { + /** PUT call to the azure file storage without any data in the body */ + @PUT("{path}") + fun put( + @Path(value = "path", encoded = true) path: String?, + @HeaderMap headers: MutableMap?, + @QueryMap query: MutableMap? + ): Call + + /** PUT call to the azure file storage with data in the body */ + @PUT("{path}") + fun putData( + @Path(value = "path", encoded = true) path: String?, + @HeaderMap headers: MutableMap?, + @QueryMap query: MutableMap?, + @Body content: RequestBody? + ): Call + + /** HEAD call to the azure file storage */ + @HEAD("{path}") + fun head( + @Path(value = "path", encoded = true) path: String?, + @HeaderMap headers: MutableMap?, + @QueryMap query: MutableMap? + ): Call +} 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..02e425be1 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/delay/DelayedUploader.kt @@ -0,0 +1,112 @@ +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. + */ + 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..759af8c35 --- /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: (String?, CommitInfo?) -> TeamscaleServer +) : 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( + 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/TeamscaleConfig.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/teamscale/TeamscaleConfig.kt new file mode 100644 index 000000000..78be3093f --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/teamscale/TeamscaleConfig.kt @@ -0,0 +1,171 @@ +package com.teamscale.jacoco.agent.upload.teamscale + +import com.teamscale.client.CommitDescriptor +import com.teamscale.client.StringUtils.isEmpty +import com.teamscale.client.TeamscaleServer +import com.teamscale.jacoco.agent.options.AgentOptionParseException +import com.teamscale.jacoco.agent.options.AgentOptionsParser +import com.teamscale.jacoco.agent.options.FilePatternResolver +import com.teamscale.report.util.BashFileSkippingInputStream +import com.teamscale.report.util.ILogger +import java.io.File +import java.io.IOException +import java.nio.file.Files +import java.util.jar.JarInputStream +import java.util.jar.Manifest + +/** Config necessary for direct Teamscale upload. */ +class TeamscaleConfig( + private val logger: ILogger, + private val filePatternResolver: FilePatternResolver +) { + /** + * Handles all command line options prefixed with "teamscale-". + * @return true if it has successfully processed the given option. + */ + @Throws(AgentOptionParseException::class) + fun handleTeamscaleOptions( + teamscaleServer: TeamscaleServer, + key: String, value: String + ): Boolean { + when (key) { + "teamscale-server-url" -> { + teamscaleServer.url = AgentOptionsParser.parseUrl(key, value) + return true + } + "teamscale-project" -> { + teamscaleServer.project = value + return true + } + "teamscale-user" -> { + teamscaleServer.userName = value + return true + } + "teamscale-access-token" -> { + teamscaleServer.userAccessToken = value + return true + } + TEAMSCALE_PARTITION_OPTION -> { + teamscaleServer.partition = value + return true + } + TEAMSCALE_COMMIT_OPTION -> { + teamscaleServer.commit = parseCommit(value) + return true + } + TEAMSCALE_COMMIT_MANIFEST_JAR_OPTION -> { + val path = AgentOptionsParser.parsePath(filePatternResolver, key, value) ?: return false + teamscaleServer.commit = getCommitFromManifest(path.toFile()) + return true + } + "teamscale-message" -> { + teamscaleServer.message = value + return true + } + TEAMSCALE_REVISION_OPTION -> { + teamscaleServer.revision = value + return true + } + "teamscale-repository" -> { + teamscaleServer.repository = value + return true + } + TEAMSCALE_REVISION_MANIFEST_JAR_OPTION -> { + val path = AgentOptionsParser.parsePath(filePatternResolver, key, value) ?: return false + teamscaleServer.revision = getRevisionFromManifest(path.toFile()) + return true + } + else -> return false + } + } + + /** + * Parses the the string representation of a commit to a [CommitDescriptor] object. + * The expected format is "branch:timestamp". + */ + @Throws(AgentOptionParseException::class) + private fun parseCommit(commit: String): CommitDescriptor { + val split = commit.split(":".toRegex()).dropLastWhile { it.isEmpty() } + if (split.size != 2) { + throw AgentOptionParseException("Invalid commit given $commit") + } + return CommitDescriptor(split[0], split[1]) + } + + /** + * Reads `Branch` and `Timestamp` entries from the given jar/war file's manifest and builds a commit descriptor out + * of it. + */ + @Throws(AgentOptionParseException::class) + private fun getCommitFromManifest(jarFile: File): CommitDescriptor { + val manifest = getManifestFromJarFile(jarFile) + val branch = manifest.mainAttributes.getValue("Branch") + val timestamp = manifest.mainAttributes.getValue("Timestamp") + if (branch.isNullOrEmpty()) { + throw AgentOptionParseException("No entry 'Branch' in MANIFEST") + } else if (timestamp.isNullOrEmpty()) { + throw AgentOptionParseException("No entry 'Timestamp' in MANIFEST") + } + logger.debug("Found commit $branch:$timestamp in file $jarFile") + return CommitDescriptor(branch, timestamp) + } + + /** + * Reads `Git_Commit` entry from the given jar/war file's manifest and sets it as revision. + */ + @Throws(AgentOptionParseException::class) + private fun getRevisionFromManifest(jarFile: File): String { + val manifest = getManifestFromJarFile(jarFile) + var revision = manifest.mainAttributes.getValue("Revision") + if (revision.isNullOrEmpty()) { + // currently needed option for a customer + if (manifest.getAttributes("Git") != null) { + revision = manifest.getAttributes("Git").getValue("Git_Commit") + } + + if (revision.isNullOrEmpty()) { + throw AgentOptionParseException("No entry 'Revision' in MANIFEST") + } + } + logger.debug("Found revision $revision in file $jarFile") + return revision + } + + /** + * Reads the JarFile to extract the MANIFEST.MF. + */ + @Throws(AgentOptionParseException::class) + private fun getManifestFromJarFile(jarFile: File): Manifest { + try { + JarInputStream( + BashFileSkippingInputStream(Files.newInputStream(jarFile.toPath())) + ).use { jarStream -> + val manifest = jarStream.manifest ?: throw AgentOptionParseException( + "Unable to read manifest from $jarFile. Maybe the manifest is corrupt?" + ) + return manifest + } + } catch (e: IOException) { + throw AgentOptionParseException( + "Reading jar ${jarFile.absolutePath} for obtaining commit descriptor from MANIFEST failed", e + ) + } + } + + companion object { + /** Option name that allows to specify to which branch coverage should be uploaded to (branch:timestamp). */ + const val TEAMSCALE_COMMIT_OPTION: String = "teamscale-commit" + + /** Option name that allows to specify a git commit hash to which coverage should be uploaded to. */ + const val TEAMSCALE_REVISION_OPTION: String = "teamscale-revision" + + /** Option name that allows to specify a jar file that contains the git commit hash in a MANIFEST.MF file. */ + const val TEAMSCALE_REVISION_MANIFEST_JAR_OPTION: String = "teamscale-revision-manifest-jar" + + /** Option name that allows to specify a jar file that contains the branch name and timestamp in a MANIFEST.MF file. */ + const val TEAMSCALE_COMMIT_MANIFEST_JAR_OPTION: String = "teamscale-commit-manifest-jar" + + /** Option name that allows to specify a partition to which coverage should be uploaded to. */ + const val TEAMSCALE_PARTITION_OPTION: String = "teamscale-partition" + } +} 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..87ce3b8b7 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/teamscale/TeamscaleUploader.kt @@ -0,0 +1,149 @@ +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.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/AgentHttpServerTest.java b/agent/src/test/java/com/teamscale/jacoco/agent/AgentHttpServerTest.java deleted file mode 100644 index 3a813e989..000000000 --- a/agent/src/test/java/com/teamscale/jacoco/agent/AgentHttpServerTest.java +++ /dev/null @@ -1,137 +0,0 @@ -package com.teamscale.jacoco.agent; - -import com.teamscale.client.TeamscaleServer; -import com.teamscale.jacoco.agent.options.AgentOptions; -import com.teamscale.jacoco.agent.options.TestAgentOptionsBuilder; -import okhttp3.HttpUrl; -import okhttp3.MediaType; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.RequestBody; -import okhttp3.Response; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import java.net.URI; -import java.net.URISyntaxException; - -import static org.assertj.core.api.Assertions.assertThat; - -public class AgentHttpServerTest { - - private Agent agent; - private final URI baseUri; - private final Integer httpServerPort = 8081; - private final String defaultCommitMessage = "Some Message"; - private final String defaultPartition = "Some Partition"; - - public AgentHttpServerTest() throws URISyntaxException { - baseUri = new URI("http://localhost:" + httpServerPort); - } - - /** Starts the http server to control the agent */ - @BeforeEach - public void setup() throws Exception { - AgentOptions options = new TestAgentOptionsBuilder() - .withHttpServerPort(httpServerPort) - .withTeamscaleMessage(defaultCommitMessage) - .withTeamscalePartition(defaultPartition) - .create(); - - agent = new Agent(options, null); - } - - /** Stops the http server */ - @AfterEach - public void teardown() { - agent.stopServer(); - } - - /** Test overwriting the commit message */ - @Test - public void testOverridingMessage() throws Exception { - String newMessage = "New Message"; - - putText("/message", newMessage); - - TeamscaleServer teamscaleServer = agent.options.getTeamscaleServerOptions(); - assertThat(teamscaleServer.getMessage()).isEqualTo(newMessage); - } - - /** Test reading the commit message */ - @Test - public void testGettingMessage() throws Exception { - String receivedMessage = getText("/message"); - - assertThat(receivedMessage).isEqualTo(defaultCommitMessage); - } - - /** Test overwriting the partition */ - @Test - public void testOverridingPartition() throws Exception { - String newPartition = "New Partition"; - - putText("/partition", newPartition); - - TeamscaleServer teamscaleServer = agent.options.getTeamscaleServerOptions(); - assertThat(teamscaleServer.partition).isEqualTo(newPartition); - } - - /** Test reading the partition */ - @Test - public void testGettingPartition() throws Exception { - String receivedPartition = getText("/partition"); - - assertThat(receivedPartition).isEqualTo(defaultPartition); - } - - /** Test reading the commit to which the agent will upload. */ - @Test - public void testGettingCommit() throws Exception { - String receivedCommit = getText("/commit"); - assertThat(receivedCommit).isEqualTo("{\"type\":\"REVISION\",\"value\":null}"); - receivedCommit = getJson("/commit"); - assertThat(receivedCommit).isEqualTo("{\"type\":\"REVISION\",\"value\":null}"); - } - - /** Test reading the revision to which the agent will upload. */ - @Test - public void testGettingRevision() throws Exception { - String receivedRevision = getText("/revision"); - assertThat(receivedRevision).isEqualTo("{\"type\":\"REVISION\",\"value\":null}"); - receivedRevision = getJson("/revision"); - assertThat(receivedRevision).isEqualTo("{\"type\":\"REVISION\",\"value\":null}"); - } - - private void putText(String endpointPath, String newValue) throws Exception { - OkHttpClient client = new OkHttpClient(); - MediaType textPlainMediaType = MediaType.parse("text/plain; charset=utf-8"); - HttpUrl endpointUrl = HttpUrl.get(baseUri.resolve(endpointPath)); - Request request = new Request.Builder() - .url(endpointUrl) - .method("PUT", RequestBody.create(textPlainMediaType, newValue.getBytes())) - .build(); - client.newCall(request).execute(); - } - - private String getText(String endpointPath) throws Exception { - OkHttpClient client = new OkHttpClient(); - HttpUrl endpointUrl = HttpUrl.get(baseUri.resolve(endpointPath)); - Request request = new Request.Builder() - .url(endpointUrl) - .build(); - Response response = client.newCall(request).execute(); - return response.body() != null ? response.body().string() : ""; - } - - private String getJson(String endpointPath) throws Exception { - OkHttpClient client = new OkHttpClient(); - HttpUrl endpointUrl = HttpUrl.get(baseUri.resolve(endpointPath)); - Request request = new Request.Builder() - .url(endpointUrl).header("Accept", javax.ws.rs.core.MediaType.APPLICATION_JSON) - .build(); - Response response = client.newCall(request).execute(); - return response.body() != null ? response.body().string() : ""; - } -} 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 deleted file mode 100644 index ec38ee8b6..000000000 --- a/agent/src/test/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitMultiProjectPropertiesLocatorTest.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.TeamscaleServer; -import com.teamscale.jacoco.agent.options.ProjectAndCommit; -import com.teamscale.jacoco.agent.upload.teamscale.DelayedTeamscaleMultiProjectUploader; -import com.teamscale.jacoco.agent.upload.teamscale.TeamscaleUploader; -import org.junit.jupiter.api.Test; - -import java.io.File; -import java.util.ArrayList; -import java.util.List; -import java.util.stream.Collectors; - -import static org.assertj.core.api.Assertions.assertThat; - -class GitMultiProjectPropertiesLocatorTest { - @Test - void testNoErrorIsThrownWhenGitPropertiesFileDoesNotHaveAProject() { - - List projectAndCommits = new ArrayList<>(); - GitMultiProjectPropertiesLocator locator = new GitMultiProjectPropertiesLocator( - new DelayedTeamscaleMultiProjectUploader((project, revision) -> { - projectAndCommits.add(new ProjectAndCommit(project, revision)); - return new TeamscaleServer(); - }), true, null); - 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"); - } - - @Test - void testNoMultipleUploadsToSameProjectAndRevision() { - DelayedTeamscaleMultiProjectUploader delayedTeamscaleMultiProjectUploader = new DelayedTeamscaleMultiProjectUploader( - (project, revision) -> { - TeamscaleServer server = new TeamscaleServer(); - server.project = project; - server.revision = revision.revision; - server.commit = revision.commit; - return server; - }); - GitMultiProjectPropertiesLocator locator = new GitMultiProjectPropertiesLocator( - delayedTeamscaleMultiProjectUploader, true, null - ); - 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()); - assertThat(teamscaleServers).hasSize(2); - assertThat(teamscaleServers).anyMatch(server -> server.project.equals("demo2") && server.commit.equals( - new CommitDescriptor("master", "1645713803000"))); - assertThat(teamscaleServers).anyMatch(server -> server.project.equals("demolib") && server.revision.equals( - "05b9d066a0c0762be622987de403b5752fa01cc0")); - } - -} diff --git a/agent/src/test/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitPropertiesLocatorTest.java b/agent/src/test/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitPropertiesLocatorTest.java deleted file mode 100644 index b93a5bb28..000000000 --- a/agent/src/test/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitPropertiesLocatorTest.java +++ /dev/null @@ -1,191 +0,0 @@ -package com.teamscale.jacoco.agent.commit_resolution.git_properties; - -import kotlin.Pair; -import org.junit.jupiter.api.Test; - -import java.io.File; -import java.time.format.DateTimeFormatter; -import java.util.Arrays; -import java.util.List; -import java.util.Properties; -import java.util.jar.JarInputStream; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -public class GitPropertiesLocatorTest { - - private static final List TEST_ARCHIVES = Arrays - .asList("plain-git-properties.jar", "spring-boot-git-properties.jar", "spring-boot-git-properties.war", - "full-git-properties.jar", "spring-boot-3.jar"); - - @Test - public void testReadingGitPropertiesFromArchive() throws Exception { - for (String archiveName : TEST_ARCHIVES) { - JarInputStream jarInputStream = new JarInputStream(getClass().getResourceAsStream(archiveName)); - List> commits = GitPropertiesLocatorUtils - .findGitPropertiesInArchive(jarInputStream, archiveName, true); - assertThat(commits.size()).isEqualTo(1); - String rev = GitPropertiesLocatorUtils - .getCommitInfoFromGitProperties(commits.get(0).getSecond(), "test", - new File("test.jar"), null).revision; - assertThat(rev).withFailMessage("Wrong commit found in " + archiveName) - .isEqualTo("72c7b3f7e6c4802414283cdf7622e6127f3f8976"); - } - } - - /** - * Checks if extraction of git.properties works for nested jar files. - */ - @Test - public void testReadingGitPropertiesFromNestedArchive() throws Exception { - File nestedArchiveFile = new File(getClass().getResource("nested-jar.war").toURI()); - List> commits = GitPropertiesLocatorUtils.findGitPropertiesInFile(nestedArchiveFile, - true, true); - assertThat(commits.size()).isEqualTo(2); // First git.properties in the root war, 2nd in the nested Jar - String rev = GitPropertiesLocatorUtils - .getCommitInfoFromGitProperties(commits.get(1).getSecond(), - "test", - new File("test.jar"), null).revision; - assertThat(rev).isEqualTo("5b3b2d44987be38f930fe57128274e317316423d"); - } - - @Test - public void testReadingGitPropertiesInJarFileNestedInFolder() throws Exception { - File folder = new File(getClass().getResource("multiple-git-properties-folder").toURI()); - List> commits = GitPropertiesLocatorUtils.findGitPropertiesInFile(folder, false, true); - assertThat(commits.size()).isEqualTo(2); - Pair firstFind = commits.get(0); - Pair secondFind = commits.get(1); - assertThat(firstFind.getFirst()).isEqualTo( - "multiple-git-properties-folder" + File.separator + "WEB-INF" + File.separator + "classes" + - File.separator + "git.properties"); - assertThat(secondFind.getFirst()).isEqualTo( - "multiple-git-properties-folder" + File.separator + "WEB-INF" + File.separator + "lib" + - File.separator + "demoLib-1.1-SNAPSHOT.jar" + File.separator + "git.properties"); - } - - @Test - public void testGitPropertiesWithInvalidTimestamp() { - Properties gitProperties = new Properties(); - gitProperties.put("git.commit.time", "123ab"); - gitProperties.put("git.branch", "master"); - assertThatThrownBy( - () -> GitPropertiesLocatorUtils.getCommitInfoFromGitProperties(gitProperties, "test", - new File("test.jar"), null)) - .isInstanceOf(InvalidGitPropertiesException.class); - } - - @Test - public void testReadingTeamscaleTimestampFromProperties() throws InvalidGitPropertiesException { - Properties properties = new Properties(); - String branchName = "myBranch"; - String timestamp = "42"; - properties.setProperty(GitPropertiesLocatorUtils.GIT_PROPERTIES_TEAMSCALE_COMMIT_BRANCH, branchName); - properties.setProperty(GitPropertiesLocatorUtils.GIT_PROPERTIES_TEAMSCALE_COMMIT_TIME, timestamp); - CommitInfo commitInfo = GitPropertiesLocatorUtils.getCommitInfoFromGitProperties(properties, - "myEntry", new File("myJarFile"), null); - assertThat(commitInfo.commit.timestamp).isEqualTo(timestamp); - assertThat(commitInfo.commit.branchName).isEqualTo(branchName); - } - - @Test - public void testTeamscaleTimestampIsOverwritingCommitBranchAndTime() throws InvalidGitPropertiesException { - Properties properties = new Properties(); - String teamscaleTimestampBranch = "myBranch1"; - String teamscaleTimestampTime = "42"; - String gitCommitBranch = "myBranch2"; - String gitCommitTime = "2024-05-13T16:42:03+02:00"; - properties.setProperty(GitPropertiesLocatorUtils.GIT_PROPERTIES_TEAMSCALE_COMMIT_BRANCH, - teamscaleTimestampBranch); - properties.setProperty(GitPropertiesLocatorUtils.GIT_PROPERTIES_TEAMSCALE_COMMIT_TIME, teamscaleTimestampTime); - properties.setProperty(GitPropertiesLocatorUtils.GIT_PROPERTIES_GIT_BRANCH, gitCommitBranch); - properties.setProperty(GitPropertiesLocatorUtils.GIT_PROPERTIES_GIT_COMMIT_TIME, gitCommitTime); - CommitInfo commitInfo = GitPropertiesLocatorUtils.getCommitInfoFromGitProperties(properties, - "myEntry", new File("myJarFile"), null); - assertThat(commitInfo.commit.timestamp).isEqualTo(teamscaleTimestampTime); - assertThat(commitInfo.commit.branchName).isEqualTo(teamscaleTimestampBranch); - } - - @Test - public void testCommitBranchAndTimeIsUsedIfNoTeamscaleTimestampIsGiven() throws InvalidGitPropertiesException { - Properties properties = new Properties(); - String gitCommitBranch = "myBranch2"; - String gitCommitTime = "2024-05-13T16:42:03+02:00"; - String epochTimestamp = "1715611323000"; - properties.setProperty(GitPropertiesLocatorUtils.GIT_PROPERTIES_GIT_BRANCH, gitCommitBranch); - properties.setProperty(GitPropertiesLocatorUtils.GIT_PROPERTIES_GIT_COMMIT_TIME, gitCommitTime); - CommitInfo commitInfo = GitPropertiesLocatorUtils.getCommitInfoFromGitProperties(properties, - "myEntry", new File("myJarFile"), null); - assertThat(commitInfo.commit.timestamp).isEqualTo(epochTimestamp); - assertThat(commitInfo.commit.branchName).isEqualTo(gitCommitBranch); - } - - @Test - public void testTeamscaleTimestampCanContainLocalTime() throws InvalidGitPropertiesException { - Properties properties = new Properties(); - String branchName = "myBranch"; - String timestamp = "2024-05-13T16:42:03+02:00"; - String epochTimestamp = "1715611323000"; - properties.setProperty(GitPropertiesLocatorUtils.GIT_PROPERTIES_TEAMSCALE_COMMIT_BRANCH, branchName); - properties.setProperty(GitPropertiesLocatorUtils.GIT_PROPERTIES_TEAMSCALE_COMMIT_TIME, timestamp); - CommitInfo commitInfo = GitPropertiesLocatorUtils.getCommitInfoFromGitProperties(properties, - "myEntry", new File("myJarFile"), null); - assertThat(commitInfo.commit.timestamp).isEqualTo(epochTimestamp); - assertThat(commitInfo.commit.branchName).isEqualTo(branchName); - } - - @Test - public void testRevisionAndTimestampAreBothReadIfPresent() throws InvalidGitPropertiesException { - Properties properties = new Properties(); - String branchName = "myBranch"; - String timestamp = "2024-05-13T16:42:03+02:00"; - String revision = "ab1337cd"; - String epochTimestamp = "1715611323000"; - properties.setProperty(GitPropertiesLocatorUtils.GIT_PROPERTIES_TEAMSCALE_COMMIT_BRANCH, branchName); - properties.setProperty(GitPropertiesLocatorUtils.GIT_PROPERTIES_TEAMSCALE_COMMIT_TIME, timestamp); - properties.setProperty(GitPropertiesLocatorUtils.GIT_PROPERTIES_GIT_COMMIT_ID, revision); - CommitInfo commitInfo = GitPropertiesLocatorUtils.getCommitInfoFromGitProperties(properties, - "myEntry", new File("myJarFile"), null); - assertThat(commitInfo.commit.timestamp).isEqualTo(epochTimestamp); - assertThat(commitInfo.commit.branchName).isEqualTo(branchName); - assertThat(commitInfo.revision).isNotEmpty(); - } - - @Test - public void testPreferCommitDescriptorOverRevisionIsSetWhenTeamscaleTimestampIsPresentInGitProperties() throws InvalidGitPropertiesException { - Properties properties = new Properties(); - String branchName = "myBranch"; - String timestamp = "2024-05-13T16:42:03+02:00"; - properties.setProperty(GitPropertiesLocatorUtils.GIT_PROPERTIES_TEAMSCALE_COMMIT_BRANCH, branchName); - properties.setProperty(GitPropertiesLocatorUtils.GIT_PROPERTIES_TEAMSCALE_COMMIT_TIME, timestamp); - CommitInfo commitInfo = GitPropertiesLocatorUtils.getCommitInfoFromGitProperties(properties, - "myEntry", new File("myJarFile"), null); - assertThat(commitInfo.preferCommitDescriptorOverRevision).isTrue(); - } - - @Test - public void testPreferCommitDescriptorOverRevisionIsNotSetWhenTeamscaleTimestampIsNotPresentInGitProperties() throws InvalidGitPropertiesException { - Properties properties = new Properties(); - String branchName = "myBranch"; - String timestamp = "2024-05-13T16:42:03+02:00"; - properties.setProperty(GitPropertiesLocatorUtils.GIT_PROPERTIES_GIT_BRANCH, branchName); - properties.setProperty(GitPropertiesLocatorUtils.GIT_PROPERTIES_GIT_COMMIT_TIME, timestamp); - CommitInfo commitInfo = GitPropertiesLocatorUtils.getCommitInfoFromGitProperties(properties, - "myEntry", new File("myJarFile"), null); - assertThat(commitInfo.preferCommitDescriptorOverRevision).isFalse(); - } - - @Test - public void testAdditionalDateTimeFormatterIsUsed() throws InvalidGitPropertiesException { - Properties properties = new Properties(); - String branchName = "myBranch"; - String timestamp = "20240513T16:42:03+02:00"; - String epochTimestamp = "1715611323000"; - properties.setProperty(GitPropertiesLocatorUtils.GIT_PROPERTIES_GIT_BRANCH, branchName); - properties.setProperty(GitPropertiesLocatorUtils.GIT_PROPERTIES_GIT_COMMIT_TIME, timestamp); - CommitInfo commitInfo = GitPropertiesLocatorUtils.getCommitInfoFromGitProperties(properties, - "myEntry", new File("myJarFile"), DateTimeFormatter.ofPattern("yyyyMMdd'T'HH:mm:ssXXX")); - assertThat(commitInfo.commit.timestamp).isEqualTo(epochTimestamp); - } -} diff --git a/agent/src/test/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitPropertiesLocatorUtilsTest.java b/agent/src/test/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitPropertiesLocatorUtilsTest.java deleted file mode 100644 index 76098c76d..000000000 --- a/agent/src/test/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitPropertiesLocatorUtilsTest.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.teamscale.jacoco.agent.commit_resolution.git_properties; - -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; - -import java.io.File; -import java.net.URL; -import java.net.URLConnection; -import java.net.URLStreamHandler; - -import static org.assertj.core.api.Assertions.assertThat; - -class GitPropertiesLocatorUtilsTest { - - /** - * Registers a protocol handler so the test can construct "nested:" URLs that are not supported by plain Java - * but Spring boot. - */ - @BeforeAll - public static void registerCatchAllUrlProtocol() { - URL.setURLStreamHandlerFactory(protocol -> { - if (!"nested".equals(protocol)) { - return null; - } - return new URLStreamHandler() { - /** Returns null, since opening the connection is never done in the test.: */ - protected URLConnection openConnection(URL url) { - return null; - } - }; - }); - } - - @Test - public void parseSpringBootCodeLocations() throws Exception { - assertThat(GitPropertiesLocatorUtils - .extractGitPropertiesSearchRoot(new URL("jar:file:/home/k/demo.jar!/BOOT-INF/classes!/")).getFirst()) - .isEqualTo(new File("/home/k/demo.jar")); - - URL springBoot3Url = new URL( - "jar:nested:/home/k/proj/spring-boot/demo/build/libs/demo-0.0.1-SNAPSHOT.jar/!BOOT-INF/classes/!/."); - assertThat(GitPropertiesLocatorUtils.extractGitPropertiesSearchRoot(springBoot3Url).getFirst()) - .isEqualTo(new File("/home/k/proj/spring-boot/demo/build/libs/demo-0.0.1-SNAPSHOT.jar")); - } - - @Test - public void parseFileCodeLocations() throws Exception { - assertThat(GitPropertiesLocatorUtils - .extractGitPropertiesSearchRoot(new URL("file:/home/k/demo.jar")).getFirst()) - .isEqualTo(new File("/home/k/demo.jar")); - } -} \ No newline at end of file diff --git a/agent/src/test/java/com/teamscale/jacoco/agent/convert/ConverterTest.java b/agent/src/test/java/com/teamscale/jacoco/agent/convert/ConverterTest.java deleted file mode 100644 index bc0c155ec..000000000 --- a/agent/src/test/java/com/teamscale/jacoco/agent/convert/ConverterTest.java +++ /dev/null @@ -1,95 +0,0 @@ -package com.teamscale.jacoco.agent.convert; - -import com.teamscale.client.FileSystemUtils; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -import java.io.File; -import java.io.IOException; -import java.net.URISyntaxException; -import java.nio.file.Files; -import java.util.Collections; - -import static org.assertj.core.api.Assertions.assertThat; - -/** Basic smoke test for the converter. */ -public class ConverterTest { - - /** - * Ensures that running the converter on valid input does not yield any errors and produces a coverage XML report. - */ - @Test - public void testSmokeTest(@TempDir File tempDir) throws Exception { - File execFile = new File(getClass().getResource("coverage.exec").toURI()); - File classFile = new File(getClass().getResource("TestClass.class").toURI()); - File outputFile = new File(tempDir, "coverage.xml"); - - ConvertCommand arguments = new ConvertCommand(); - arguments.inputFiles = Collections.singletonList(execFile.getAbsolutePath()); - arguments.outputFile = outputFile.getAbsolutePath(); - arguments.classDirectoriesOrZips = Collections.singletonList(classFile.getAbsolutePath()); - - new Converter(arguments).runJaCoCoReportGeneration(); - - String xml = FileSystemUtils.readFileUTF8(outputFile); - assertThat(xml).isNotEmpty().contains(" configuration = SapNwdiApplication.parseApplications("com.teamscale.test2.Bar:alias"); assertThat(configuration).element(0).satisfies(application -> { - assertThat(application.getMarkerClass()).isEqualTo("com.teamscale.test2.Bar"); - assertThat(application.getTeamscaleProject()).isEqualTo("alias"); + assertThat(application.markerClass).isEqualTo("com.teamscale.test2.Bar"); + assertThat(application.teamscaleProject).isEqualTo("alias"); }); } @@ -45,12 +45,12 @@ public void testMultipleApplications() throws Exception { List configuration = SapNwdiApplication .parseApplications("com.teamscale.test1.Bar:alias; com.teamscale.test2.Bar:id"); assertThat(configuration).element(0).satisfies(application -> { - assertThat(application.getMarkerClass()).isEqualTo("com.teamscale.test1.Bar"); - assertThat(application.getTeamscaleProject()).isEqualTo("alias"); + assertThat(application.markerClass).isEqualTo("com.teamscale.test1.Bar"); + assertThat(application.teamscaleProject).isEqualTo("alias"); }); assertThat(configuration).element(1).satisfies(application -> { - assertThat(application.getMarkerClass()).isEqualTo("com.teamscale.test2.Bar"); - assertThat(application.getTeamscaleProject()).isEqualTo("id"); + assertThat(application.markerClass).isEqualTo("com.teamscale.test2.Bar"); + assertThat(application.teamscaleProject).isEqualTo("id"); }); } } diff --git a/agent/src/test/java/com/teamscale/jacoco/agent/testimpact/CoverageToTeamscaleStrategyTest.java b/agent/src/test/java/com/teamscale/jacoco/agent/testimpact/CoverageToTeamscaleStrategyTest.java deleted file mode 100644 index 2c426ce06..000000000 --- a/agent/src/test/java/com/teamscale/jacoco/agent/testimpact/CoverageToTeamscaleStrategyTest.java +++ /dev/null @@ -1,137 +0,0 @@ -package com.teamscale.jacoco.agent.testimpact; - -import com.teamscale.client.ClusteredTestDetails; -import com.teamscale.client.CommitDescriptor; -import com.teamscale.client.EReportFormat; -import com.teamscale.client.PrioritizableTest; -import com.teamscale.client.PrioritizableTestCluster; -import com.teamscale.client.TeamscaleClient; -import com.teamscale.client.TeamscaleServer; -import com.teamscale.jacoco.agent.JacocoRuntimeController; -import com.teamscale.jacoco.agent.options.AgentOptions; -import com.teamscale.report.testwise.jacoco.JaCoCoTestwiseReportGenerator; -import com.teamscale.report.testwise.model.ETestExecutionResult; -import com.teamscale.report.testwise.model.TestExecution; -import com.teamscale.report.testwise.model.TestwiseCoverage; -import com.teamscale.report.testwise.model.builder.FileCoverageBuilder; -import com.teamscale.report.testwise.model.builder.TestCoverageBuilder; -import okhttp3.HttpUrl; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.api.io.TempDir; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import retrofit2.Response; - -import java.io.File; -import java.io.IOException; -import java.util.Collections; -import java.util.List; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyBoolean; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.matches; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -public class CoverageToTeamscaleStrategyTest { - - @Mock - private TeamscaleClient client; - - @Mock - private JaCoCoTestwiseReportGenerator reportGenerator; - - @Mock - private JacocoRuntimeController controller; - - @TempDir - File tempDir; - - @Test - public void shouldRecordCoverageForTestsEvenIfNotProvidedAsAvailableTest() throws Exception { - AgentOptions options = mockOptions(false); - CoverageToTeamscaleStrategy strategy = new CoverageToTeamscaleStrategy(controller, options, reportGenerator); - - TestwiseCoverage testwiseCoverage = getDummyTestwiseCoverage("mytest"); - when(reportGenerator.convert(any(File.class))).thenReturn(testwiseCoverage); - - // we skip testRunStart and don't provide any available tests - strategy.testStart("mytest"); - strategy.testEnd("mytest", new TestExecution("mytest", 0L, ETestExecutionResult.PASSED)); - strategy.testRunEnd(false); - - verify(client).uploadReport(eq(EReportFormat.TESTWISE_COVERAGE), - matches("\\Q{\"partial\":false,\"tests\":[{\"uniformPath\":\"mytest\",\"sourcePath\":\"mytest\",\"duration\":\\E[^,]*\\Q,\"result\":\"PASSED\",\"paths\":[{\"path\":\"src/main/java\",\"files\":[{\"fileName\":\"Main.java\",\"coveredLines\":\"1-4\"}]}]}]}\\E"), - any(), any(), any(), any(), any()); - } - - @ParameterizedTest - @ValueSource(booleans = {true, false}) - public void testValidCallSequence(boolean useRevision) throws Exception { - List clusters = Collections - .singletonList(new PrioritizableTestCluster("cluster", - Collections.singletonList(new PrioritizableTest("mytest")))); - when(client.getImpactedTests(any(), any(), any(), any(), any(), any(), any(), anyBoolean(), anyBoolean(), - anyBoolean())).thenReturn( - Response.success(clusters)); - - TestwiseCoverage testwiseCoverage = getDummyTestwiseCoverage("mytest"); - when(reportGenerator.convert(any(File.class))).thenReturn(testwiseCoverage); - - AgentOptions options = mockOptions(useRevision); - JacocoRuntimeController controller = mock(JacocoRuntimeController.class); - CoverageToTeamscaleStrategy strategy = new CoverageToTeamscaleStrategy(controller, options, reportGenerator); - - strategy.testRunStart( - Collections.singletonList( - new ClusteredTestDetails("mytest", "mytest", "content", "cluster")), false, - true, true, - null, null); - strategy.testStart("mytest"); - strategy.testEnd("mytest", new TestExecution("mytest", 0L, ETestExecutionResult.PASSED)); - strategy.testRunEnd(true); - - verify(client).uploadReport(eq(EReportFormat.TESTWISE_COVERAGE), - matches("\\Q{\"partial\":true,\"tests\":[{\"uniformPath\":\"mytest\",\"sourcePath\":\"mytest\",\"content\":\"content\",\"duration\":\\E[^,]*\\Q,\"result\":\"PASSED\",\"paths\":[{\"path\":\"src/main/java\",\"files\":[{\"fileName\":\"Main.java\",\"coveredLines\":\"1-4\"}]}]}]}\\E"), - any(), any(), any(), any(), any()); - } - - /** Returns a dummy testwise coverage object for a test with the given name that covers a few lines of Main.java. */ - protected static TestwiseCoverage getDummyTestwiseCoverage(String test) { - TestCoverageBuilder testCoverageBuilder = new TestCoverageBuilder(test); - FileCoverageBuilder fileCoverageBuilder = new FileCoverageBuilder("src/main/java", "Main.java"); - fileCoverageBuilder.addLineRange(1, 4); - testCoverageBuilder.add(fileCoverageBuilder); - TestwiseCoverage testwiseCoverage = new TestwiseCoverage(); - testwiseCoverage.add(testCoverageBuilder); - return testwiseCoverage; - } - - private AgentOptions mockOptions(boolean useRevision) throws IOException { - AgentOptions options = mock(AgentOptions.class); - when(options.createTeamscaleClient(true)).thenReturn(client); - when(options.createNewFileInOutputDirectory(any(), any())).thenReturn(new File(tempDir, "test")); - - TeamscaleServer server = new TeamscaleServer(); - if (useRevision) { - server.revision = "rev1"; - } else { - server.commit = new CommitDescriptor("branch", "12345"); - } - server.url = HttpUrl.get("http://doesnt-exist.io"); - server.userName = "build"; - server.userAccessToken = "token"; - server.partition = "partition"; - when(options.getTeamscaleServerOptions()).thenReturn(server); - - when(options.createTeamscaleClient(true)).thenReturn(client); - return options; - } - -} diff --git a/agent/src/test/java/com/teamscale/jacoco/agent/testimpact/TestExecutionWriterTest.java b/agent/src/test/java/com/teamscale/jacoco/agent/testimpact/TestExecutionWriterTest.java deleted file mode 100644 index f4ef91238..000000000 --- a/agent/src/test/java/com/teamscale/jacoco/agent/testimpact/TestExecutionWriterTest.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.teamscale.jacoco.agent.testimpact; - -import com.teamscale.report.testwise.model.ETestExecutionResult; -import com.teamscale.report.testwise.model.TestExecution; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.assertj.core.api.Assertions.assertThat; - -class TestExecutionWriterTest { - - @Test - public void testOneExecution(@TempDir Path tempDir) throws Exception { - Path tempFile = tempDir.resolve("executions.json"); - TestExecutionWriter writer = new TestExecutionWriter(tempFile.toFile()); - writer.append(new TestExecution("test1", 123, ETestExecutionResult.PASSED)); - String json = String.join("\n", Files.readAllLines(tempFile)); - assertThat(json).isEqualTo("[{\"uniformPath\":\"test1\",\"durationMillis\":123,\"result\":\"PASSED\"}]"); - } - - @Test - public void testMultipleExecutions(@TempDir Path tempDir) throws Exception { - Path tempFile = tempDir.resolve("executions.json"); - TestExecutionWriter writer = new TestExecutionWriter(tempFile.toFile()); - writer.append(new TestExecution("test1", 123, ETestExecutionResult.PASSED)); - writer.append(new TestExecution("test2", 123, ETestExecutionResult.PASSED)); - writer.append(new TestExecution("test3", 123, ETestExecutionResult.PASSED)); - String json = String.join("\n", Files.readAllLines(tempFile)); - assertThat(json).isEqualTo("[{\"uniformPath\":\"test1\",\"durationMillis\":123,\"result\":\"PASSED\"}" + - ",{\"uniformPath\":\"test2\",\"durationMillis\":123,\"result\":\"PASSED\"}" + - ",{\"uniformPath\":\"test3\",\"durationMillis\":123,\"result\":\"PASSED\"}]"); - } - -} diff --git a/agent/src/test/java/com/teamscale/jacoco/agent/testimpact/TestwiseCoverageAgentTest.java b/agent/src/test/java/com/teamscale/jacoco/agent/testimpact/TestwiseCoverageAgentTest.java deleted file mode 100644 index 903bd53e9..000000000 --- a/agent/src/test/java/com/teamscale/jacoco/agent/testimpact/TestwiseCoverageAgentTest.java +++ /dev/null @@ -1,178 +0,0 @@ -package com.teamscale.jacoco.agent.testimpact; - -import com.teamscale.client.ClusteredTestDetails; -import com.teamscale.client.CommitDescriptor; -import com.teamscale.client.EReportFormat; -import com.teamscale.client.PrioritizableTest; -import com.teamscale.client.PrioritizableTestCluster; -import com.teamscale.client.TeamscaleClient; -import com.teamscale.client.TeamscaleServer; -import com.teamscale.jacoco.agent.options.AgentOptions; -import com.teamscale.jacoco.agent.options.ETestwiseCoverageMode; -import com.teamscale.jacoco.agent.util.TestUtils; -import com.teamscale.report.testwise.jacoco.JaCoCoTestwiseReportGenerator; -import com.teamscale.report.testwise.model.ETestExecutionResult; -import com.teamscale.tia.client.RunningTest; -import com.teamscale.tia.client.TestRun; -import com.teamscale.tia.client.TestRunWithClusteredSuggestions; -import com.teamscale.tia.client.TiaAgent; -import okhttp3.HttpUrl; -import okhttp3.MediaType; -import okhttp3.ResponseBody; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.api.io.TempDir; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import retrofit2.Call; -import retrofit2.Response; -import retrofit2.Retrofit; -import retrofit2.converter.jackson.JacksonConverterFactory; -import retrofit2.http.POST; -import retrofit2.http.Query; - -import java.io.File; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatCode; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyBoolean; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.matches; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -public class TestwiseCoverageAgentTest { - private static final String FORBIDDEN_MESSAGE_PREFIX = "HTTP Status Code: 403 Forbidden\nMessage: "; - private static final String MISSING_VIEW_PERMISSIONS = "User doesn't have permission 'VIEW' on project x."; - private static final MediaType PLAIN_TEXT = MediaType.parse("plain/text"); - - @Mock - private TeamscaleClient client; - - @Mock - private JaCoCoTestwiseReportGenerator reportGenerator; - - @TempDir - File tempDir; - - @Test - public void testAccessViaTiaClientAndReportUploadToTeamscale() throws Exception { - List availableTests = Arrays - .asList(new ClusteredTestDetails("test1", "test1", "content", "cluster"), - new ClusteredTestDetails("test2", "test2", "content", "cluster")); - List impactedClusters = Collections - .singletonList(new PrioritizableTestCluster("cluster", - Collections.singletonList(new PrioritizableTest("test2")))); - when(client.getImpactedTests(any(), any(), any(), any(), any(), any(), any(), anyBoolean(), anyBoolean(), anyBoolean())) - .thenReturn(Response.success(impactedClusters)); - - when(reportGenerator.convert(any(File.class))) - .thenReturn(CoverageToTeamscaleStrategyTest.getDummyTestwiseCoverage("test2")); - - int port; - synchronized (TestUtils.class) { - port = TestUtils.getFreePort(); - AgentOptions options = mockOptions(port); - when(options.createNewFileInOutputDirectory(any(), any())).thenReturn(new File(tempDir, "test")); - new TestwiseCoverageAgent(options, null, reportGenerator); - } - - TiaAgent agent = new TiaAgent(false, HttpUrl.get("http://localhost:" + port)); - - TestRunWithClusteredSuggestions testRun = agent.startTestRun(availableTests); - assertThat(testRun.prioritizedClusters).hasSize(1); - assertThat(testRun.prioritizedClusters.get(0).tests).hasSize(1); - PrioritizableTest test = testRun.prioritizedClusters.get(0).tests.get(0); - assertThat(test.testName).isEqualTo("test2"); - - RunningTest runningTest = testRun.startTest(test.testName); - runningTest.endTest(new TestRun.TestResultWithMessage(ETestExecutionResult.PASSED, "message")); - - testRun.endTestRun(true); - verify(client).uploadReport(eq(EReportFormat.TESTWISE_COVERAGE), - matches("\\Q{\"partial\":true,\"tests\":[{\"uniformPath\":\"test1\",\"sourcePath\":\"test1\",\"content\":\"content\",\"paths\":[]},{\"uniformPath\":\"test2\",\"sourcePath\":\"test2\",\"content\":\"content\",\"duration\":\\E[^,]*\\Q,\"result\":\"PASSED\",\"message\":\"message\",\"paths\":[{\"path\":\"src/main/java\",\"files\":[{\"fileName\":\"Main.java\",\"coveredLines\":\"1-4\"}]}]}]}\\E"), - any(), any(), any(), any(), any()); - } - - @Test - public void testErrorHandling() throws Exception { - when(client.getImpactedTests(any(), any(), any(), any(), any(), any(), any(), anyBoolean(), anyBoolean(), anyBoolean())) - .thenReturn(Response.error(403, ResponseBody.create(FORBIDDEN_MESSAGE_PREFIX + MISSING_VIEW_PERMISSIONS, - PLAIN_TEXT))); - - int port; - synchronized (TestUtils.class) { - port = TestUtils.getFreePort(); - AgentOptions options = mockOptions(port); - new TestwiseCoverageAgent(options, null, reportGenerator); - } - - TiaAgent agent = new TiaAgent(false, HttpUrl.get("http://localhost:" + port)); - assertThatCode(agent::startTestRunAssumingUnchangedTests).hasMessageContaining(MISSING_VIEW_PERMISSIONS); - } - - private interface ITestwiseCoverageAgentApiWithoutBody { - - /** - * Version of testrun/start that doesn't have a body. This can't be triggered via the Java TIA client but is a - * supported version of the API for other clients. - */ - @POST("testrun/start") - Call> testRunStarted( - @Query("include-non-impacted") boolean includeNonImpacted, - @Query("baseline") Long baseline - ); - - } - - @Test - public void shouldHandleMissingRequestBodyForTestrunStartGracefully() throws Exception { - List impactedClusters = Collections - .singletonList(new PrioritizableTestCluster("cluster", - Collections.singletonList(new PrioritizableTest("test2")))); - when(client.getImpactedTests(any(), any(), any(), any(), any(), any(), any(), anyBoolean(), anyBoolean(), anyBoolean())) - .thenReturn(Response.success(impactedClusters)); - - int port; - synchronized (TestUtils.class) { - port = TestUtils.getFreePort(); - new TestwiseCoverageAgent(mockOptions(port), null, reportGenerator); - } - - ITestwiseCoverageAgentApiWithoutBody api = new Retrofit.Builder() - .addConverterFactory(JacksonConverterFactory.create()) - .baseUrl("http://localhost:" + port) - .build().create(ITestwiseCoverageAgentApiWithoutBody.class); - Response> response = api.testRunStarted(false, null).execute(); - - assertThat(response.isSuccessful()).describedAs(response.toString()).isTrue(); - List tests = response.body(); - assertThat(tests).isNotNull().hasSize(1); - assertThat(tests.get(0).tests).hasSize(1); - } - - private AgentOptions mockOptions(int port) { - AgentOptions options = mock(AgentOptions.class); - when(options.createTeamscaleClient(true)).thenReturn(client); - - - TeamscaleServer server = new TeamscaleServer(); - server.commit = new CommitDescriptor("branch", "12345"); - server.url = HttpUrl.get("http://doesnt-exist.io"); - server.userName = "build"; - server.userAccessToken = "token"; - server.partition = "partition"; - when(options.getTeamscaleServerOptions()).thenReturn(server); - when(options.getHttpServerPort()).thenReturn(port); - when(options.getTestwiseCoverageMode()).thenReturn(ETestwiseCoverageMode.TEAMSCALE_UPLOAD); - - when(options.createTeamscaleClient(true)).thenReturn(client); - return options; - } -} diff --git a/agent/src/test/java/com/teamscale/jacoco/agent/util/InMemoryUploader.java b/agent/src/test/java/com/teamscale/jacoco/agent/util/InMemoryUploader.java deleted file mode 100644 index e6aad060e..000000000 --- a/agent/src/test/java/com/teamscale/jacoco/agent/util/InMemoryUploader.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.teamscale.jacoco.agent.util; - -import java.io.File; -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; - -import com.teamscale.jacoco.agent.upload.IUploader; -import com.teamscale.report.jacoco.CoverageFile; - -/** - * Simulates an upload by storing coverage {@link File} in a list. The - * "uploaded" Files can then be retrieved with - * {@link InMemoryUploader#getUploadedFiles()} - */ -public class InMemoryUploader implements IUploader { - private final List coverageFiles = new ArrayList<>(); - - @Override - public void upload(CoverageFile coverageFile) { - coverageFiles.add(coverageFile); - try { - coverageFile.delete(); - } catch (IOException e) { - // Do nothing as not being able to delete the file is not important for tests - } - } - - @Override - public String describe() { - return "in memory uploader"; - } - - public List getUploadedFiles() { - return coverageFiles; - } -} diff --git a/agent/src/test/java/com/teamscale/jacoco/agent/util/TestUtils.java b/agent/src/test/java/com/teamscale/jacoco/agent/util/TestUtils.java deleted file mode 100644 index d20c47386..000000000 --- a/agent/src/test/java/com/teamscale/jacoco/agent/util/TestUtils.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.teamscale.jacoco.agent.util; - -import java.io.IOException; -import java.net.ServerSocket; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.stream.Stream; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * Test Utilities - */ -public class TestUtils { - /** - * Deletes all contents inside the coverage folder inside the agent directory - */ - public static void cleanAgentCoverageDirectory() throws IOException { - Path coverageDir = AgentUtils.getAgentDirectory().resolve("coverage"); - if (Files.exists(coverageDir)) { - try (Stream stream = Files.list(coverageDir)) { - stream.forEach(path -> - assertThat(path.toFile().delete()).withFailMessage("Failed to delete " + path).isTrue()); - } - Files.delete(coverageDir); - } - } - - /** Returns a new free TCP port number */ - public static int getFreePort() throws IOException { - try (ServerSocket socket = new ServerSocket(0)) { - socket.setReuseAddress(true); - return socket.getLocalPort(); - } - } -} diff --git a/agent/src/test/kotlin/com/teamscale/jacoco/agent/AgentHttpServerTest.kt b/agent/src/test/kotlin/com/teamscale/jacoco/agent/AgentHttpServerTest.kt new file mode 100644 index 000000000..2fb8c7b1b --- /dev/null +++ b/agent/src/test/kotlin/com/teamscale/jacoco/agent/AgentHttpServerTest.kt @@ -0,0 +1,141 @@ +package com.teamscale.jacoco.agent + +import com.teamscale.jacoco.agent.options.TestAgentOptionsBuilder +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.net.URI + +class AgentHttpServerTest { + private var agent: Agent? = null + private val baseUri: URI + private val httpServerPort = 8081 + private val defaultCommitMessage = "Some Message" + private val defaultPartition = "Some Partition" + + init { + baseUri = URI("http://localhost:$httpServerPort") + } + + /** Starts the http server to control the agent */ + @BeforeEach + @Throws(Exception::class) + fun setup() { + val options = TestAgentOptionsBuilder() + .withHttpServerPort(httpServerPort) + .withTeamscaleMessage(defaultCommitMessage) + .withTeamscalePartition(defaultPartition) + .create() + + agent = Agent(options, null) + } + + /** Stops the http server */ + @AfterEach + fun teardown() { + agent!!.stopServer() + } + + /** Test overwriting the commit message */ + @Test + @Throws(Exception::class) + fun testOverridingMessage() { + val newMessage = "New Message" + + putText("/message", newMessage) + + val teamscaleServer = agent!!.options.teamscaleServer + Assertions.assertThat(teamscaleServer.message).isEqualTo(newMessage) + } + + /** Test reading the commit message */ + @Test + @Throws(Exception::class) + fun testGettingMessage() { + val receivedMessage = getText("/message") + + Assertions.assertThat(receivedMessage).isEqualTo(defaultCommitMessage) + } + + /** Test overwriting the partition */ + @Test + @Throws(Exception::class) + fun testOverridingPartition() { + val newPartition = "New Partition" + + putText("/partition", newPartition) + + val teamscaleServer = agent!!.options.teamscaleServer + Assertions.assertThat(teamscaleServer.partition).isEqualTo(newPartition) + } + + /** Test reading the partition */ + @Test + @Throws(Exception::class) + fun testGettingPartition() { + val receivedPartition = getText("/partition") + + Assertions.assertThat(receivedPartition).isEqualTo(defaultPartition) + } + + /** Test reading the commit to which the agent will upload. */ + @Test + @Throws(Exception::class) + fun testGettingCommit() { + var receivedCommit = getText("/commit") + Assertions.assertThat(receivedCommit).isEqualTo("{\"type\":\"REVISION\",\"value\":null}") + receivedCommit = getJson("/commit") + Assertions.assertThat(receivedCommit).isEqualTo("{\"type\":\"REVISION\",\"value\":null}") + } + + /** Test reading the revision to which the agent will upload. */ + @Test + @Throws(Exception::class) + fun testGettingRevision() { + var receivedRevision = getText("/revision") + Assertions.assertThat(receivedRevision).isEqualTo("{\"type\":\"REVISION\",\"value\":null}") + receivedRevision = getJson("/revision") + Assertions.assertThat(receivedRevision).isEqualTo("{\"type\":\"REVISION\",\"value\":null}") + } + + @Throws(Exception::class) + private fun putText(endpointPath: String, newValue: String) { + val client = OkHttpClient() + val textPlainMediaType = "text/plain; charset=utf-8".toMediaTypeOrNull() + val endpointUrl = "$baseUri$endpointPath".toHttpUrlOrNull() + val content = newValue.toByteArray() + val request = Request.Builder() + .url(endpointUrl!!) + .method("PUT", content.toRequestBody(textPlainMediaType, 0, content.size)) + .build() + client.newCall(request).execute() + } + + @Throws(Exception::class) + private fun getText(endpointPath: String): String { + val client = OkHttpClient() + val endpointUrl = "$baseUri$endpointPath".toHttpUrlOrNull() + val request = Request.Builder() + .url(endpointUrl!!) + .build() + val response = client.newCall(request).execute() + return response.body.string() + } + + @Throws(Exception::class) + private fun getJson(endpointPath: String): String { + val client = OkHttpClient() + val endpointUrl = "$baseUri$endpointPath".toHttpUrlOrNull() + val request = Request.Builder() + .url(endpointUrl!!).header("Accept", javax.ws.rs.core.MediaType.APPLICATION_JSON) + .build() + val response = client.newCall(request).execute() + return response.body.string() + } +} diff --git a/agent/src/test/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitMultiProjectPropertiesLocatorTest.kt b/agent/src/test/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitMultiProjectPropertiesLocatorTest.kt new file mode 100644 index 000000000..c5800602c --- /dev/null +++ b/agent/src/test/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitMultiProjectPropertiesLocatorTest.kt @@ -0,0 +1,57 @@ +package com.teamscale.jacoco.agent.commit_resolution.git_properties + +import com.teamscale.client.CommitDescriptor +import com.teamscale.client.TeamscaleServer +import com.teamscale.jacoco.agent.options.ProjectAndCommit +import com.teamscale.jacoco.agent.upload.teamscale.DelayedTeamscaleMultiProjectUploader +import com.teamscale.jacoco.agent.upload.teamscale.TeamscaleUploader +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.Test +import java.io.File +import java.util.function.BiFunction +import java.util.function.Predicate +import java.util.stream.Collectors + +internal class GitMultiProjectPropertiesLocatorTest { + @Test + fun testNoErrorIsThrownWhenGitPropertiesFileDoesNotHaveAProject() { + val projectAndCommits = mutableListOf() + val locator = GitMultiProjectPropertiesLocator( + DelayedTeamscaleMultiProjectUploader { project, revision -> + projectAndCommits.add(ProjectAndCommit(project, revision)) + TeamscaleServer() + }, true, null + ) + val jarFile = File(javaClass.getResource("emptyTeamscaleProjectGitProperties")!!.file) + locator.searchFile(jarFile, false) + Assertions.assertThat(projectAndCommits.size).isEqualTo(1) + Assertions.assertThat(projectAndCommits.first().project).isEqualTo("my-teamscale-project") + } + + @Test + fun testNoMultipleUploadsToSameProjectAndRevision() { + val delayedTeamscaleMultiProjectUploader = DelayedTeamscaleMultiProjectUploader { project, revision -> + val server = TeamscaleServer() + server.project = project + server.revision = revision!!.revision + server.commit = revision.commit + server + } + val locator = GitMultiProjectPropertiesLocator( + delayedTeamscaleMultiProjectUploader, true, null + ) + val jarFile = File(javaClass.getResource("multiple-same-target-git-properties-folder")!!.file) + locator.searchFile(jarFile, false) + val teamscaleServers = delayedTeamscaleMultiProjectUploader.teamscaleUploaders.map { it.teamscaleServer } + Assertions.assertThat(teamscaleServers).hasSize(2) + Assertions.assertThat(teamscaleServers).anyMatch { server -> + server.project == "demo2" && server.commit == CommitDescriptor( + "master", + "1645713803000" + ) + } + Assertions.assertThat(teamscaleServers).anyMatch { server -> + server.project == "demolib" && server.revision == "05b9d066a0c0762be622987de403b5752fa01cc0" + } + } +} diff --git a/agent/src/test/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitPropertiesLocatorTest.kt b/agent/src/test/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitPropertiesLocatorTest.kt new file mode 100644 index 000000000..95445da01 --- /dev/null +++ b/agent/src/test/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitPropertiesLocatorTest.kt @@ -0,0 +1,216 @@ +package com.teamscale.jacoco.agent.commit_resolution.git_properties + +import com.teamscale.jacoco.agent.commit_resolution.git_properties.GitPropertiesLocatorUtils.findGitPropertiesInArchive +import com.teamscale.jacoco.agent.commit_resolution.git_properties.GitPropertiesLocatorUtils.findGitPropertiesInFile +import com.teamscale.jacoco.agent.commit_resolution.git_properties.GitPropertiesLocatorUtils.getCommitInfoFromGitProperties +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.Test +import java.io.File +import java.time.format.DateTimeFormatter +import java.util.* +import java.util.jar.JarInputStream + +class GitPropertiesLocatorTest { + @Test + @Throws(Exception::class) + fun testReadingGitPropertiesFromArchive() { + for (archiveName in TEST_ARCHIVES) { + val jarInputStream = JarInputStream(javaClass.getResourceAsStream(archiveName)) + val commits = findGitPropertiesInArchive(jarInputStream, archiveName, true) + Assertions.assertThat(commits.size).isEqualTo(1) + val rev = getCommitInfoFromGitProperties( + commits.first().second, "test", + File("test.jar"), null + ).revision + Assertions.assertThat(rev).withFailMessage("Wrong commit found in $archiveName") + .isEqualTo("72c7b3f7e6c4802414283cdf7622e6127f3f8976") + } + } + + /** + * Checks if extraction of git.properties works for nested jar files. + */ + @Test + @Throws(Exception::class) + fun testReadingGitPropertiesFromNestedArchive() { + val nestedArchiveFile = File(javaClass.getResource("nested-jar.war")!!.toURI()) + val commits = findGitPropertiesInFile( + nestedArchiveFile, isJarFile = true, recursiveSearch = true + ) + Assertions.assertThat(commits.size).isEqualTo(2) // First git.properties in the root war, 2nd in the nested Jar + val rev = getCommitInfoFromGitProperties( + commits[1].second, + "test", + File("test.jar"), null + ).revision + Assertions.assertThat(rev).isEqualTo("5b3b2d44987be38f930fe57128274e317316423d") + } + + @Test + @Throws(Exception::class) + fun testReadingGitPropertiesInJarFileNestedInFolder() { + val folder = File(javaClass.getResource("multiple-git-properties-folder")!!.toURI()) + val commits = findGitPropertiesInFile(folder, isJarFile = false, recursiveSearch = true) + Assertions.assertThat(commits.size).isEqualTo(2) + val firstFind = commits.first() + val secondFind = commits[1] + Assertions.assertThat(firstFind.first).isEqualTo( + "multiple-git-properties-folder${File.separator}WEB-INF${File.separator}classes${File.separator}git.properties" + ) + Assertions.assertThat(secondFind.first).isEqualTo( + "multiple-git-properties-folder${File.separator}WEB-INF${File.separator}lib${File.separator}demoLib-1.1-SNAPSHOT.jar${File.separator}git.properties" + ) + } + + @Test + fun testGitPropertiesWithInvalidTimestamp() { + val gitProperties = Properties() + gitProperties["git.commit.time"] = "123ab" + gitProperties["git.branch"] = "master" + Assertions.assertThatThrownBy { + getCommitInfoFromGitProperties( + gitProperties, "test", + File("test.jar"), null + ) + }.isInstanceOf(InvalidGitPropertiesException::class.java) + } + + @Test + @Throws(InvalidGitPropertiesException::class) + fun testReadingTeamscaleTimestampFromProperties() { + val properties = Properties() + val branchName = "myBranch" + val timestamp = "42" + properties.setProperty(GitPropertiesLocatorUtils.GIT_PROPERTIES_TEAMSCALE_COMMIT_BRANCH, branchName) + properties.setProperty(GitPropertiesLocatorUtils.GIT_PROPERTIES_TEAMSCALE_COMMIT_TIME, timestamp) + val commitInfo = getCommitInfoFromGitProperties( + properties, + "myEntry", File("myJarFile"), null + ) + Assertions.assertThat(commitInfo.commit!!.timestamp).isEqualTo(timestamp) + Assertions.assertThat(commitInfo.commit!!.branchName).isEqualTo(branchName) + } + + @Test + @Throws(InvalidGitPropertiesException::class) + fun testTeamscaleTimestampIsOverwritingCommitBranchAndTime() { + val properties = Properties() + val teamscaleTimestampBranch = "myBranch1" + val teamscaleTimestampTime = "42" + val gitCommitBranch = "myBranch2" + val gitCommitTime = "2024-05-13T16:42:03+02:00" + properties.setProperty( + GitPropertiesLocatorUtils.GIT_PROPERTIES_TEAMSCALE_COMMIT_BRANCH, + teamscaleTimestampBranch + ) + properties.setProperty(GitPropertiesLocatorUtils.GIT_PROPERTIES_TEAMSCALE_COMMIT_TIME, teamscaleTimestampTime) + properties.setProperty(GitPropertiesLocatorUtils.GIT_PROPERTIES_GIT_BRANCH, gitCommitBranch) + properties.setProperty(GitPropertiesLocatorUtils.GIT_PROPERTIES_GIT_COMMIT_TIME, gitCommitTime) + val commitInfo = getCommitInfoFromGitProperties( + properties, "myEntry", File("myJarFile"), null + ) + Assertions.assertThat(commitInfo.commit!!.timestamp).isEqualTo(teamscaleTimestampTime) + Assertions.assertThat(commitInfo.commit!!.branchName).isEqualTo(teamscaleTimestampBranch) + } + + @Test + @Throws(InvalidGitPropertiesException::class) + fun testCommitBranchAndTimeIsUsedIfNoTeamscaleTimestampIsGiven() { + val properties = Properties() + val gitCommitBranch = "myBranch2" + val gitCommitTime = "2024-05-13T16:42:03+02:00" + val epochTimestamp = "1715611323000" + properties.setProperty(GitPropertiesLocatorUtils.GIT_PROPERTIES_GIT_BRANCH, gitCommitBranch) + properties.setProperty(GitPropertiesLocatorUtils.GIT_PROPERTIES_GIT_COMMIT_TIME, gitCommitTime) + val commitInfo = getCommitInfoFromGitProperties( + properties, "myEntry", File("myJarFile"), null + ) + Assertions.assertThat(commitInfo.commit!!.timestamp).isEqualTo(epochTimestamp) + Assertions.assertThat(commitInfo.commit!!.branchName).isEqualTo(gitCommitBranch) + } + + @Test + @Throws(InvalidGitPropertiesException::class) + fun testTeamscaleTimestampCanContainLocalTime() { + val properties = Properties() + val branchName = "myBranch" + val timestamp = "2024-05-13T16:42:03+02:00" + val epochTimestamp = "1715611323000" + properties.setProperty(GitPropertiesLocatorUtils.GIT_PROPERTIES_TEAMSCALE_COMMIT_BRANCH, branchName) + properties.setProperty(GitPropertiesLocatorUtils.GIT_PROPERTIES_TEAMSCALE_COMMIT_TIME, timestamp) + val commitInfo = getCommitInfoFromGitProperties( + properties, "myEntry", File("myJarFile"), null + ) + Assertions.assertThat(commitInfo.commit!!.timestamp).isEqualTo(epochTimestamp) + Assertions.assertThat(commitInfo.commit!!.branchName).isEqualTo(branchName) + } + + @Test + @Throws(InvalidGitPropertiesException::class) + fun testRevisionAndTimestampAreBothReadIfPresent() { + val properties = Properties() + val branchName = "myBranch" + val timestamp = "2024-05-13T16:42:03+02:00" + val revision = "ab1337cd" + val epochTimestamp = "1715611323000" + properties.setProperty(GitPropertiesLocatorUtils.GIT_PROPERTIES_TEAMSCALE_COMMIT_BRANCH, branchName) + properties.setProperty(GitPropertiesLocatorUtils.GIT_PROPERTIES_TEAMSCALE_COMMIT_TIME, timestamp) + properties.setProperty(GitPropertiesLocatorUtils.GIT_PROPERTIES_GIT_COMMIT_ID, revision) + val commitInfo = getCommitInfoFromGitProperties( + properties, "myEntry", File("myJarFile"), null + ) + Assertions.assertThat(commitInfo.commit!!.timestamp).isEqualTo(epochTimestamp) + Assertions.assertThat(commitInfo.commit!!.branchName).isEqualTo(branchName) + Assertions.assertThat(commitInfo.revision).isNotEmpty() + } + + @Test + @Throws(InvalidGitPropertiesException::class) + fun testPreferCommitDescriptorOverRevisionIsSetWhenTeamscaleTimestampIsPresentInGitProperties() { + val properties = Properties() + val branchName = "myBranch" + val timestamp = "2024-05-13T16:42:03+02:00" + properties.setProperty(GitPropertiesLocatorUtils.GIT_PROPERTIES_TEAMSCALE_COMMIT_BRANCH, branchName) + properties.setProperty(GitPropertiesLocatorUtils.GIT_PROPERTIES_TEAMSCALE_COMMIT_TIME, timestamp) + val commitInfo = getCommitInfoFromGitProperties( + properties, "myEntry", File("myJarFile"), null + ) + Assertions.assertThat(commitInfo.preferCommitDescriptorOverRevision).isTrue() + } + + @Test + @Throws(InvalidGitPropertiesException::class) + fun testPreferCommitDescriptorOverRevisionIsNotSetWhenTeamscaleTimestampIsNotPresentInGitProperties() { + val properties = Properties() + val branchName = "myBranch" + val timestamp = "2024-05-13T16:42:03+02:00" + properties.setProperty(GitPropertiesLocatorUtils.GIT_PROPERTIES_GIT_BRANCH, branchName) + properties.setProperty(GitPropertiesLocatorUtils.GIT_PROPERTIES_GIT_COMMIT_TIME, timestamp) + val commitInfo = getCommitInfoFromGitProperties( + properties, "myEntry", File("myJarFile"), null + ) + Assertions.assertThat(commitInfo.preferCommitDescriptorOverRevision).isFalse() + } + + @Test + @Throws(InvalidGitPropertiesException::class) + fun testAdditionalDateTimeFormatterIsUsed() { + val properties = Properties() + val branchName = "myBranch" + val timestamp = "20240513T16:42:03+02:00" + val epochTimestamp = "1715611323000" + properties.setProperty(GitPropertiesLocatorUtils.GIT_PROPERTIES_GIT_BRANCH, branchName) + properties.setProperty(GitPropertiesLocatorUtils.GIT_PROPERTIES_GIT_COMMIT_TIME, timestamp) + val commitInfo = getCommitInfoFromGitProperties( + properties, "myEntry", File("myJarFile"), DateTimeFormatter.ofPattern("yyyyMMdd'T'HH:mm:ssXXX") + ) + Assertions.assertThat(commitInfo.commit!!.timestamp).isEqualTo(epochTimestamp) + } + + companion object { + private val TEST_ARCHIVES = listOf( + "plain-git-properties.jar", "spring-boot-git-properties.jar", "spring-boot-git-properties.war", + "full-git-properties.jar", "spring-boot-3.jar" + ) + } +} diff --git a/agent/src/test/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitPropertiesLocatorUtilsTest.kt b/agent/src/test/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitPropertiesLocatorUtilsTest.kt new file mode 100644 index 000000000..c66169ed5 --- /dev/null +++ b/agent/src/test/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitPropertiesLocatorUtilsTest.kt @@ -0,0 +1,59 @@ +package com.teamscale.jacoco.agent.commit_resolution.git_properties + +import com.teamscale.jacoco.agent.commit_resolution.git_properties.GitPropertiesLocatorUtils.extractGitPropertiesSearchRoot +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import java.io.File +import java.net.URI +import java.net.URL +import java.net.URLConnection +import java.net.URLStreamHandler + +internal class GitPropertiesLocatorUtilsTest { + @Test + @Throws(Exception::class) + fun parseSpringBootCodeLocations() { + val url = URI.create("jar:file:/home/k/demo.jar!/BOOT-INF/classes!/").toURL() + Assertions.assertThat( + extractGitPropertiesSearchRoot(url)!!.first + ).isEqualTo(File("/home/k/demo.jar")) + + val springBoot3Url = URI.create( + "jar:nested:/home/k/proj/spring-boot/demo/build/libs/demo-0.0.1-SNAPSHOT.jar/!BOOT-INF/classes/!/." + ).toURL() + Assertions.assertThat(extractGitPropertiesSearchRoot(springBoot3Url)!!.first) + .isEqualTo(File("/home/k/proj/spring-boot/demo/build/libs/demo-0.0.1-SNAPSHOT.jar")) + } + + @Test + @Throws(Exception::class) + fun parseFileCodeLocations() { + val url = URI.create("file:/home/k/demo.jar").toURL() + Assertions.assertThat( + extractGitPropertiesSearchRoot(url)!!.first + ).isEqualTo(File("/home/k/demo.jar")) + } + + companion object { + /** + * Registers a protocol handler so the test can construct "nested:" URLs that are not supported by plain Java + * but Spring boot. + */ + @JvmStatic + @BeforeAll + fun registerCatchAllUrlProtocol() { + URL.setURLStreamHandlerFactory { protocol -> + if ("nested" != protocol) { + return@setURLStreamHandlerFactory null + } + object : URLStreamHandler() { + /** Returns null, since opening the connection is never done in the test.: */ + override fun openConnection(url: URL?): URLConnection? { + return null + } + } + } + } + } +} \ No newline at end of file diff --git a/agent/src/test/kotlin/com/teamscale/jacoco/agent/convert/ConverterTest.kt b/agent/src/test/kotlin/com/teamscale/jacoco/agent/convert/ConverterTest.kt new file mode 100644 index 000000000..937e0fe7d --- /dev/null +++ b/agent/src/test/kotlin/com/teamscale/jacoco/agent/convert/ConverterTest.kt @@ -0,0 +1,91 @@ +package com.teamscale.jacoco.agent.convert + +import com.teamscale.client.FileSystemUtils.readFileUTF8 +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File +import java.io.IOException +import java.net.URISyntaxException +import java.nio.file.Files + +/** Basic smoke test for the converter. */ +class ConverterTest { + /** + * Ensures that running the converter on valid input does not yield any errors and produces a coverage XML report. + */ + @Test + @Throws(Exception::class) + fun testSmokeTest(@TempDir tempDir: File?) { + val execFile = File(javaClass.getResource("coverage.exec")!!.toURI()) + val classFile = File(javaClass.getResource("TestClass.class")!!.toURI()) + val outputFile = File(tempDir, "coverage.xml") + + val arguments = ConvertCommand() + arguments.inputFiles = mutableListOf(execFile.absolutePath) + arguments.outputFile = outputFile.absolutePath + arguments.classDirectoriesOrZips = mutableListOf(classFile.absolutePath) + + Converter(arguments).runJaCoCoReportGeneration() + + val xml = readFileUTF8(outputFile) + Assertions.assertThat(xml).isNotEmpty().contains("())).thenReturn(testwiseCoverage) + + // we skip testRunStart and don't provide any available tests + strategy.testStart("mytest") + strategy.testEnd("mytest", TestExecution("mytest", 0L, ETestExecutionResult.PASSED)) + strategy.testRunEnd(false) + + verify(client).uploadReport( + eq(EReportFormat.TESTWISE_COVERAGE), + matches("\\Q{\"partial\":false,\"tests\":[{\"uniformPath\":\"mytest\",\"sourcePath\":\"mytest\",\"duration\":\\E[^,]*\\Q,\"result\":\"PASSED\",\"paths\":[{\"path\":\"src/main/java\",\"files\":[{\"fileName\":\"Main.java\",\"coveredLines\":\"1-4\"}]}]}]}\\E"), + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull() + ) + } + + @ParameterizedTest + @ValueSource(booleans = [true, false]) + @Throws(Exception::class) + fun testValidCallSequence(useRevision: Boolean) { + val clusters = listOf( + PrioritizableTestCluster( + "cluster", + listOf(PrioritizableTest("mytest")) + ) + ) + + whenever( + client.getImpactedTests( + anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), + anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull() + ) + ).thenReturn(Response.success(clusters)) + + val testwiseCoverage = getDummyTestwiseCoverage("mytest") + whenever(reportGenerator.convert(any())).thenReturn(testwiseCoverage) + + val options = mockOptions(useRevision) + val strategy = CoverageToTeamscaleStrategy(controller, options, reportGenerator) + + strategy.testRunStart( + listOf(ClusteredTestDetails("mytest", "mytest", "content", "cluster")), + false, + true, + true, + null, + null + ) + strategy.testStart("mytest") + strategy.testEnd("mytest", TestExecution("mytest", 0L, ETestExecutionResult.PASSED)) + strategy.testRunEnd(true) + + verify(client).uploadReport( + eq(EReportFormat.TESTWISE_COVERAGE), + matches("\\Q{\"partial\":true,\"tests\":[{\"uniformPath\":\"mytest\",\"sourcePath\":\"mytest\",\"content\":\"content\",\"duration\":\\E[^,]*\\Q,\"result\":\"PASSED\",\"paths\":[{\"path\":\"src/main/java\",\"files\":[{\"fileName\":\"Main.java\",\"coveredLines\":\"1-4\"}]}]}]}\\E"), + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull() + ) + } + + @Throws(IOException::class) + private fun mockOptions(useRevision: Boolean): AgentOptions { + val options = mock() + whenever(options.createTeamscaleClient(true)).thenReturn(client) + whenever(options.createNewFileInOutputDirectory(any(), any())).thenReturn(File(tempDir, "test")) + + val server = TeamscaleServer().apply { + if (useRevision) { + revision = "rev1" + } else { + commit = CommitDescriptor("branch", "12345") + } + url = "https://doesnt-exist.io".toHttpUrl() + userName = "build" + userAccessToken = "token" + partition = "partition" + } + options.teamscaleServer = server + + return options + } + + companion object { + /** Returns a dummy testwise coverage object for a test with the given name that covers a few lines of Main.java. */ + @JvmStatic + fun getDummyTestwiseCoverage(test: String): TestwiseCoverage { + val testCoverageBuilder = TestCoverageBuilder(test) + val fileCoverageBuilder = FileCoverageBuilder("src/main/java", "Main.java") + fileCoverageBuilder.addLineRange(1, 4) + testCoverageBuilder.add(fileCoverageBuilder) + val testwiseCoverage = TestwiseCoverage() + testwiseCoverage.add(testCoverageBuilder) + return testwiseCoverage + } + } +} \ No newline at end of file diff --git a/agent/src/test/kotlin/com/teamscale/jacoco/agent/testimpact/TestExecutionWriterTest.kt b/agent/src/test/kotlin/com/teamscale/jacoco/agent/testimpact/TestExecutionWriterTest.kt new file mode 100644 index 000000000..6580f2a9b --- /dev/null +++ b/agent/src/test/kotlin/com/teamscale/jacoco/agent/testimpact/TestExecutionWriterTest.kt @@ -0,0 +1,40 @@ +package com.teamscale.jacoco.agent.testimpact + +import com.teamscale.report.testwise.model.ETestExecutionResult +import com.teamscale.report.testwise.model.TestExecution +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Files +import java.nio.file.Path +import kotlin.Exception +import kotlin.Throws + +internal class TestExecutionWriterTest { + @Test + @Throws(Exception::class) + fun testOneExecution(@TempDir tempDir: Path) { + val tempFile = tempDir.resolve("executions.json") + val writer = TestExecutionWriter(tempFile.toFile()) + writer.append(TestExecution("test1", 123, ETestExecutionResult.PASSED)) + val json = Files.readAllLines(tempFile).joinToString("\n") + Assertions.assertThat(json) + .isEqualTo("[{\"uniformPath\":\"test1\",\"durationMillis\":123,\"result\":\"PASSED\"}]") + } + + @Test + @Throws(Exception::class) + fun testMultipleExecutions(@TempDir tempDir: Path) { + val tempFile = tempDir.resolve("executions.json") + val writer = TestExecutionWriter(tempFile.toFile()) + writer.append(TestExecution("test1", 123, ETestExecutionResult.PASSED)) + writer.append(TestExecution("test2", 123, ETestExecutionResult.PASSED)) + writer.append(TestExecution("test3", 123, ETestExecutionResult.PASSED)) + val json = Files.readAllLines(tempFile).joinToString("\n") + Assertions.assertThat(json).isEqualTo( + "[{\"uniformPath\":\"test1\",\"durationMillis\":123,\"result\":\"PASSED\"}" + + ",{\"uniformPath\":\"test2\",\"durationMillis\":123,\"result\":\"PASSED\"}" + + ",{\"uniformPath\":\"test3\",\"durationMillis\":123,\"result\":\"PASSED\"}]" + ) + } +} diff --git a/agent/src/test/kotlin/com/teamscale/jacoco/agent/testimpact/TestwiseCoverageAgentTest.kt b/agent/src/test/kotlin/com/teamscale/jacoco/agent/testimpact/TestwiseCoverageAgentTest.kt new file mode 100644 index 000000000..8140e01dd --- /dev/null +++ b/agent/src/test/kotlin/com/teamscale/jacoco/agent/testimpact/TestwiseCoverageAgentTest.kt @@ -0,0 +1,214 @@ +package com.teamscale.jacoco.agent.testimpact + +import com.teamscale.client.ClusteredTestDetails +import com.teamscale.client.CommitDescriptor +import com.teamscale.client.EReportFormat +import com.teamscale.client.PrioritizableTest +import com.teamscale.client.PrioritizableTestCluster +import com.teamscale.client.TeamscaleClient +import com.teamscale.client.TeamscaleServer +import com.teamscale.jacoco.agent.options.AgentOptions +import com.teamscale.jacoco.agent.options.ETestwiseCoverageMode +import com.teamscale.jacoco.agent.util.TestUtils +import com.teamscale.report.testwise.jacoco.JaCoCoTestwiseReportGenerator +import com.teamscale.report.testwise.model.ETestExecutionResult +import com.teamscale.tia.client.RunningTest +import com.teamscale.tia.client.TestRun +import com.teamscale.tia.client.TestRunWithClusteredSuggestions +import com.teamscale.tia.client.TiaAgent +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.ResponseBody.Companion.toResponseBody +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatCode +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.io.TempDir +import org.mockito.ArgumentMatchers.matches +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import retrofit2.Call +import retrofit2.Response +import retrofit2.Retrofit +import retrofit2.converter.jackson.JacksonConverterFactory +import retrofit2.http.POST +import retrofit2.http.Query +import java.io.File + +@ExtendWith(MockitoExtension::class) +class TestwiseCoverageAgentTest { + + @Mock + private lateinit var client: TeamscaleClient + + @Mock + private lateinit var reportGenerator: JaCoCoTestwiseReportGenerator + + @TempDir + lateinit var tempDir: File + + @Test + @Throws(Exception::class) + fun testAccessViaTiaClientAndReportUploadToTeamscale() { + val availableTests = listOf( + ClusteredTestDetails("test1", "test1", "content", "cluster"), + ClusteredTestDetails("test2", "test2", "content", "cluster") + ) + val impactedClusters = listOf( + PrioritizableTestCluster( + "cluster", + listOf(PrioritizableTest("test2")) + ) + ) + + whenever( + client.getImpactedTests( + anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), + anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull() + ) + ).thenReturn(Response.success(impactedClusters)) + + whenever(reportGenerator.convert(any())).thenReturn( + CoverageToTeamscaleStrategyTest.getDummyTestwiseCoverage("test2") + ) + + val port: Int + synchronized(TestUtils::class.java) { + port = TestUtils.getFreePort() + val options = mockOptions(port) + whenever(options.createNewFileInOutputDirectory(anyOrNull(), anyOrNull())) + .thenReturn(File(tempDir, "test")) + TestwiseCoverageAgent(options, null, reportGenerator) + } + + val agent = TiaAgent(false, "http://localhost:$port".toHttpUrl()) + + val testRun = agent.startTestRun(availableTests) + assertThat(testRun.prioritizedClusters).hasSize(1) + assertThat(testRun.prioritizedClusters!!.first().tests).hasSize(1) + + val test = testRun.prioritizedClusters!!.first().tests!!.first() + assertThat(test.testName).isEqualTo("test2") + + testRun.startTest(test.testName) + .endTest(TestRun.TestResultWithMessage(ETestExecutionResult.PASSED, "message")) + + testRun.endTestRun(true) + + verify(client).uploadReport( + eq(EReportFormat.TESTWISE_COVERAGE), + matches("\\Q{\"partial\":true,\"tests\":[{\"uniformPath\":\"test1\",\"sourcePath\":\"test1\",\"content\":\"content\",\"paths\":[]},{\"uniformPath\":\"test2\",\"sourcePath\":\"test2\",\"content\":\"content\",\"duration\":\\E[^,]*\\Q,\"result\":\"PASSED\",\"message\":\"message\",\"paths\":[{\"path\":\"src/main/java\",\"files\":[{\"fileName\":\"Main.java\",\"coveredLines\":\"1-4\"}]}]}]}\\E"), + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull() + ) + } + + @Test + @Throws(Exception::class) + fun testErrorHandling() { + val errorBody = (FORBIDDEN_MESSAGE_PREFIX + MISSING_VIEW_PERMISSIONS).toResponseBody(PLAIN_TEXT) + whenever( + client.getImpactedTests( + anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), + anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull() + ) + ).thenReturn(Response.error(403, errorBody)) + + val port: Int + synchronized(TestUtils::class.java) { + port = TestUtils.getFreePort() + val options = mockOptions(port) + TestwiseCoverageAgent(options, null, reportGenerator) + } + + val agent = TiaAgent(false, "http://localhost:$port".toHttpUrl()) + assertThatCode { agent.startTestRunAssumingUnchangedTests() } + .hasMessageContaining(MISSING_VIEW_PERMISSIONS) + } + + private interface ITestwiseCoverageAgentApiWithoutBody { + /** + * Version of testrun/start that doesn't have a body. This can't be triggered via the Java TIA client but is a + * supported version of the API for other clients. + */ + @POST("testrun/start") + fun testRunStarted( + @Query("include-non-impacted") includeNonImpacted: Boolean, + @Query("baseline") baseline: Long? + ): Call> + } + + @Test + @Throws(Exception::class) + fun shouldHandleMissingRequestBodyForTestrunStartGracefully() { + val impactedClusters = listOf( + PrioritizableTestCluster( + "cluster", + listOf(PrioritizableTest("test2")) + ) + ) + + whenever( + client.getImpactedTests( + anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), + anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull() + ) + ).thenReturn(Response.success(impactedClusters)) + + val port: Int + synchronized(TestUtils::class.java) { + port = TestUtils.getFreePort() + TestwiseCoverageAgent(mockOptions(port), null, reportGenerator) + } + + val api = Retrofit.Builder() + .addConverterFactory(JacksonConverterFactory.create()) + .baseUrl("http://localhost:$port") + .build() + .create(ITestwiseCoverageAgentApiWithoutBody::class.java) + + val response = api.testRunStarted(false, null).execute() + + assertThat(response.isSuccessful).describedAs(response.toString()).isTrue() + val tests = response.body() + assertThat(tests).isNotNull.hasSize(1) + assertThat(tests!![0].tests).hasSize(1) + } + + private fun mockOptions(port: Int): AgentOptions { + val options = mock() + whenever(options.createTeamscaleClient(true)).thenReturn(client) + + val server = TeamscaleServer().apply { + commit = CommitDescriptor("branch", "12345") + url = "https://doesnt-exist.io".toHttpUrl() + userName = "build" + userAccessToken = "token" + partition = "partition" + } + + options.apply { + teamscaleServer = server + httpServerPort = port + testwiseCoverageMode = ETestwiseCoverageMode.TEAMSCALE_UPLOAD + } + + whenever(options.createTeamscaleClient(true)).thenReturn(client) + return options + } + + companion object { + private const val FORBIDDEN_MESSAGE_PREFIX = "HTTP Status Code: 403 Forbidden\nMessage: " + private const val MISSING_VIEW_PERMISSIONS = "User doesn't have permission 'VIEW' on project x." + private val PLAIN_TEXT = "plain/text".toMediaType() + } +} \ No newline at end of file diff --git a/agent/src/test/kotlin/com/teamscale/jacoco/agent/util/InMemoryUploader.kt b/agent/src/test/kotlin/com/teamscale/jacoco/agent/util/InMemoryUploader.kt new file mode 100644 index 000000000..bf52bc3c2 --- /dev/null +++ b/agent/src/test/kotlin/com/teamscale/jacoco/agent/util/InMemoryUploader.kt @@ -0,0 +1,25 @@ +package com.teamscale.jacoco.agent.util + +import com.teamscale.jacoco.agent.upload.IUploader +import com.teamscale.report.jacoco.CoverageFile +import java.io.IOException + +/** + * Simulates an upload by storing coverage [java.io.File] in a list. The + * "uploaded" Files can then be retrieved with + * [InMemoryUploader.uploadedFiles] + */ +class InMemoryUploader : IUploader { + val uploadedFiles = mutableListOf() + + override fun upload(coverageFile: CoverageFile) { + uploadedFiles.add(coverageFile) + try { + coverageFile.delete() + } catch (_: IOException) { + // Do nothing as not being able to delete the file is not important for tests + } + } + + override fun describe() = "in memory uploader" +} diff --git a/agent/src/test/kotlin/com/teamscale/jacoco/agent/util/TestUtils.kt b/agent/src/test/kotlin/com/teamscale/jacoco/agent/util/TestUtils.kt new file mode 100644 index 000000000..697a2fa34 --- /dev/null +++ b/agent/src/test/kotlin/com/teamscale/jacoco/agent/util/TestUtils.kt @@ -0,0 +1,40 @@ +package com.teamscale.jacoco.agent.util + +import com.teamscale.jacoco.agent.util.AgentUtils.agentDirectory +import org.assertj.core.api.Assertions +import java.io.IOException +import java.net.ServerSocket +import java.nio.file.Files +import java.nio.file.Path + +/** + * Test Utilities + */ +object TestUtils { + /** + * Deletes all contents inside the coverage folder inside the agent directory + */ + @JvmStatic + @Throws(IOException::class) + fun cleanAgentCoverageDirectory() { + val coverageDir = agentDirectory.resolve("coverage") + if (Files.exists(coverageDir)) { + Files.list(coverageDir).use { stream -> + stream.forEach { path: Path? -> + Assertions.assertThat(path!!.toFile().delete()).withFailMessage("Failed to delete " + path).isTrue() + } + } + Files.delete(coverageDir) + } + } + + @get:Throws(IOException::class) + val freePort: Int + /** Returns a new free TCP port number */ + get() { + ServerSocket(0).use { socket -> + socket.setReuseAddress(true) + return socket.getLocalPort() + } + } +} 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..210c76479 100644 --- a/teamscale-client/src/main/kotlin/com/teamscale/client/StringUtils.kt +++ b/teamscale-client/src/main/kotlin/com/teamscale/client/StringUtils.kt @@ -39,7 +39,7 @@ object StringUtils { if (text == null) { return true } - return EMPTY_STRING == text.trim { it <= ' ' } + return EMPTY_STRING == text.trim() } /** @@ -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")