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
index 4a19f3e3f..3fd1d616a 100644
--- a/agent/src/main/java/com/teamscale/jacoco/agent/options/AgentOptions.java
+++ b/agent/src/main/java/com/teamscale/jacoco/agent/options/AgentOptions.java
@@ -559,17 +559,17 @@ private void registerSingleGitPropertiesLocator(DelayedUploader createDelayedSingleProjectTeamscaleUploader() {
return new DelayedUploader<>(
projectAndCommit -> {
- if (!StringUtils.isEmpty(projectAndCommit.getProject()) && !teamscaleServer.project
- .equals(projectAndCommit.getProject())) {
+ if (!StringUtils.isEmpty(projectAndCommit.project) && !teamscaleServer.project
+ .equals(projectAndCommit.project)) {
logger.warn(
- "Teamscale project '" + teamscaleServer.project + "' specified in the agent configuration is not the same as the Teamscale project '" + projectAndCommit.getProject() + "' specified in git.properties file(s). Proceeding to upload to the" +
+ "Teamscale project '" + teamscaleServer.project + "' specified in the agent configuration is not the same as the Teamscale project '" + projectAndCommit.project + "' specified in git.properties file(s). Proceeding to upload to the" +
" Teamscale project '" + teamscaleServer.project + "' specified in the agent configuration.");
}
- if (projectAndCommit.getCommitInfo().preferCommitDescriptorOverRevision ||
- StringUtils.isEmpty(projectAndCommit.getCommitInfo().revision)) {
- teamscaleServer.commit = projectAndCommit.getCommitInfo().commit;
+ if (projectAndCommit.commitInfo.preferCommitDescriptorOverRevision ||
+ StringUtils.isEmpty(projectAndCommit.commitInfo.revision)) {
+ teamscaleServer.commit = projectAndCommit.commitInfo.commit;
} else {
- teamscaleServer.revision = projectAndCommit.getCommitInfo().revision;
+ teamscaleServer.revision = projectAndCommit.commitInfo.revision;
}
return new TeamscaleUploader(teamscaleServer);
}, outputDirectory);
@@ -607,7 +607,7 @@ private IUploader createDelayedArtifactoryUploader(Instrumentation instrumentati
private IUploader createNwdiTeamscaleUploader(Instrumentation instrumentation) {
DelayedSapNwdiMultiUploader uploader = new DelayedSapNwdiMultiUploader(
(commit, application) -> new TeamscaleUploader(
- teamscaleServer.withProjectAndCommit(application.getTeamscaleProject(), commit)));
+ teamscaleServer.withProjectAndCommit(application.teamscaleProject, commit)));
instrumentation.addTransformer(new NwdiMarkerClassLocatingTransformer(uploader, getLocationIncludeFilter(),
sapNetWeaverJavaApplications));
return uploader;
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/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..010e88409
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/Agent.kt
@@ -0,0 +1,170 @@
+package com.teamscale.jacoco.agent
+
+import com.teamscale.client.FileSystemUtils
+import com.teamscale.client.StringUtils
+import com.teamscale.jacoco.agent.logging.LoggingUtils
+import com.teamscale.jacoco.agent.options.AgentOptions
+import com.teamscale.jacoco.agent.upload.IUploadRetry
+import com.teamscale.jacoco.agent.upload.IUploader
+import com.teamscale.jacoco.agent.upload.teamscale.TeamscaleUploader
+import com.teamscale.jacoco.agent.util.AgentUtils
+import com.teamscale.report.jacoco.CoverageFile
+import com.teamscale.report.jacoco.EmptyReportException
+import com.teamscale.report.jacoco.JaCoCoXmlReportGenerator
+import com.teamscale.report.jacoco.dump.Dump
+import org.glassfish.jersey.server.ResourceConfig
+import org.glassfish.jersey.server.ServerProperties
+import java.io.File
+import java.io.IOException
+import java.lang.instrument.Instrumentation
+import java.nio.file.Files
+import java.util.Timer
+import kotlin.concurrent.fixedRateTimer
+import kotlin.io.path.deleteIfExists
+import kotlin.io.path.listDirectoryEntries
+import kotlin.time.DurationUnit
+import kotlin.time.toDuration
+
+/**
+ * A wrapper around the JaCoCo Java agent that automatically triggers a dump and XML conversion based on a time
+ * interval.
+ */
+class Agent(options: AgentOptions, instrumentation: Instrumentation?) : AgentBase(options) {
+ /** Converts binary data to XML. */
+ private val generator: JaCoCoXmlReportGenerator
+
+ /** Regular dump task. */
+ private var timer: Timer? = null
+
+ /** Stores the XML files. */
+ private val uploader = options.createUploader(instrumentation)
+
+ /** Constructor. */
+ init {
+ logger.info("Upload method: {}", uploader.describe())
+ retryUnsuccessfulUploads(options, uploader)
+ generator = JaCoCoXmlReportGenerator(
+ options.getClassDirectoriesOrZips(),
+ options.locationIncludeFilter,
+ options.getDuplicateClassFileBehavior(),
+ options.shouldIgnoreUncoveredClasses(),
+ LoggingUtils.wrap(logger)
+ )
+
+ if (options.shouldDumpInIntervals()) {
+ val period = options.dumpIntervalInMinutes.toDuration(DurationUnit.MINUTES).inWholeMilliseconds
+ timer = fixedRateTimer("Teamscale-Java-Profiler", true, period, period) {
+ dumpReport()
+ }
+ logger.info("Dumping every ${options.dumpIntervalInMinutes} minutes.")
+ }
+ options.teamscaleServerOptions.partition?.let { partition ->
+ controller.sessionId = partition
+ }
+ }
+
+ /**
+ * If we have coverage that was leftover because of previously unsuccessful coverage uploads, we retry to upload
+ * them again with the same configuration as in the previous try.
+ */
+ private fun retryUnsuccessfulUploads(options: AgentOptions, uploader: IUploader) {
+ var outputPath = options.outputDirectory
+ if (outputPath == null) {
+ // Default fallback
+ outputPath = AgentUtils.agentDirectory.resolve("coverage")
+ }
+
+ val parentPath = outputPath.parent
+ if (parentPath == null) {
+ logger.error("The output path '{}' does not have a parent path. Canceling upload retry.", outputPath.toAbsolutePath())
+ return
+ }
+
+ parentPath.toFile().walk()
+ .filter { it.name.endsWith(TeamscaleUploader.RETRY_UPLOAD_FILE_SUFFIX) }
+ .forEach { file ->
+ reuploadCoverageFromPropertiesFile(file, uploader)
+ }
+ }
+
+ private fun reuploadCoverageFromPropertiesFile(file: File, uploader: IUploader) {
+ logger.info("Retrying previously unsuccessful coverage upload for file {}.", file)
+ try {
+ val properties = FileSystemUtils.readProperties(file)
+ val coverageFile = CoverageFile(
+ File(StringUtils.stripSuffix(file.absolutePath, TeamscaleUploader.RETRY_UPLOAD_FILE_SUFFIX))
+ )
+
+ if (uploader is IUploadRetry) {
+ uploader.reupload(coverageFile, properties)
+ } else {
+ logger.info("Reupload not implemented for uploader {}", uploader.describe())
+ }
+ Files.deleteIfExists(file.toPath())
+ } catch (e: IOException) {
+ logger.error("Reuploading coverage failed. $e")
+ }
+ }
+
+ override fun initResourceConfig(): ResourceConfig? {
+ val resourceConfig = ResourceConfig()
+ resourceConfig.property(ServerProperties.WADL_FEATURE_DISABLE, true.toString())
+ AgentResource.setAgent(this)
+ return resourceConfig.register(AgentResource::class.java).register(GenericExceptionMapper::class.java)
+ }
+
+ override fun prepareShutdown() {
+ timer?.cancel()
+ if (options.shouldDumpOnExit()) dumpReport()
+
+ val dir = options.outputDirectory
+ try {
+ if (dir.listDirectoryEntries().isEmpty()) dir.deleteIfExists()
+ } catch (e: IOException) {
+ logger.info(
+ ("Could not delete empty output directory {}. "
+ + "This directory was created inside the configured output directory to be able to "
+ + "distinguish between different runs of the profiled JVM. You may delete it manually."),
+ dir, e
+ )
+ }
+ }
+
+ /**
+ * Dumps the current execution data, converts it, writes it to the output directory defined in [.options] and
+ * uploads it if an uploader is configured. Logs any errors, never throws an exception.
+ */
+ override fun dumpReport() {
+ logger.debug("Starting dump")
+
+ try {
+ dumpReportUnsafe()
+ } catch (t: Throwable) {
+ // we want to catch anything in order to avoid crashing the whole system under
+ // test
+ logger.error("Dump job failed with an exception", t)
+ }
+ }
+
+ private fun dumpReportUnsafe() {
+ val dump: Dump
+ try {
+ dump = controller.dumpAndReset()
+ } catch (e: JacocoRuntimeController.DumpException) {
+ logger.error("Dumping failed, retrying later", e)
+ return
+ }
+
+ try {
+ benchmark("Generating the XML report") {
+ val outputFile = options.createNewFileInOutputDirectory("jacoco", "xml")
+ val coverageFile = generator.convertSingleDumpToReport(dump, outputFile)
+ uploader.upload(coverageFile)
+ }
+ } catch (e: IOException) {
+ logger.error("Converting binary dump to XML failed", e)
+ } catch (e: EmptyReportException) {
+ logger.error("No coverage was collected. ${e.message}", e)
+ }
+ }
+}
\ No newline at end of file
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/AgentBase.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/AgentBase.kt
new file mode 100644
index 000000000..bfed53514
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/AgentBase.kt
@@ -0,0 +1,150 @@
+package com.teamscale.jacoco.agent
+
+import com.teamscale.jacoco.agent.logging.LoggingUtils
+import com.teamscale.jacoco.agent.options.AgentOptions
+import org.eclipse.jetty.server.Server
+import org.eclipse.jetty.server.ServerConnector
+import org.eclipse.jetty.servlet.ServletContextHandler
+import org.eclipse.jetty.servlet.ServletHolder
+import org.eclipse.jetty.util.thread.QueuedThreadPool
+import org.glassfish.jersey.server.ResourceConfig
+import org.glassfish.jersey.servlet.ServletContainer
+import org.jacoco.agent.rt.RT
+import org.slf4j.Logger
+import java.lang.management.ManagementFactory
+
+/**
+ * Base class for agent implementations. Handles logger shutdown, store creation and instantiation of the
+ * [JacocoRuntimeController].
+ *
+ *
+ * Subclasses must handle dumping onto disk and uploading via the configured uploader.
+ */
+abstract class AgentBase(
+ /** The agent options. */
+ @JvmField var options: AgentOptions
+) {
+ /** The logger. */
+ val logger: Logger = LoggingUtils.getLogger(this)
+
+ /** Controls the JaCoCo runtime. */
+ @JvmField
+ val controller: JacocoRuntimeController
+
+ private lateinit var server: Server
+
+ /**
+ * Lazily generated string representation of the command line arguments to print to the log.
+ */
+ private val optionsObjectToLog by lazy {
+ object {
+ override fun toString() =
+ if (options.shouldObfuscateSecurityRelatedOutputs()) {
+ options.getObfuscatedOptionsString()
+ } else {
+ options.getOriginalOptionsString()
+ }
+ }
+ }
+
+ init {
+ try {
+ controller = JacocoRuntimeController(RT.getAgent())
+ } catch (e: IllegalStateException) {
+ throw IllegalStateException("Teamscale Java Profiler not started or there is a conflict with another agent on the classpath.", e)
+ }
+ logger.info(
+ "Starting Teamscale Java Profiler for process {} with options: {}",
+ ManagementFactory.getRuntimeMXBean().name, optionsObjectToLog
+ )
+ options.getHttpServerPort()?.let { port ->
+ try {
+ initServer()
+ } catch (e: Exception) {
+ logger.error("Could not start http server on port $port. Please check if the port is blocked.")
+ throw IllegalStateException("Control server not started.", e)
+ }
+ }
+ }
+
+ /**
+ * Starts the http server, which waits for information about started and finished tests.
+ */
+ @Throws(Exception::class)
+ private fun initServer() {
+ logger.info("Listening for test events on port {}.", options.getHttpServerPort())
+
+ // Jersey Implementation
+ val handler = buildUsingResourceConfig()
+ val threadPool = QueuedThreadPool()
+ threadPool.maxThreads = 10
+ threadPool.isDaemon = true
+
+ // Create a server instance and set the thread pool
+ server = Server(threadPool)
+ // Create a server connector, set the port and add it to the server
+ val connector = ServerConnector(server)
+ connector.port = options.getHttpServerPort()
+ server.addConnector(connector)
+ server.handler = handler
+ server.start()
+ }
+
+ private fun buildUsingResourceConfig(): ServletContextHandler {
+ val handler = ServletContextHandler(ServletContextHandler.NO_SESSIONS)
+ handler.contextPath = "/"
+
+ val resourceConfig = initResourceConfig()
+ handler.addServlet(ServletHolder(ServletContainer(resourceConfig)), "/*")
+ return handler
+ }
+
+ /**
+ * Initializes the [ResourceConfig] needed for the Jetty + Jersey Server
+ */
+ protected abstract fun initResourceConfig(): ResourceConfig?
+
+ /**
+ * Registers a shutdown hook that stops the timer and dumps coverage a final time.
+ */
+ fun registerShutdownHook() {
+ Runtime.getRuntime().addShutdownHook(Thread {
+ try {
+ logger.info("Teamscale Java Profiler is shutting down...")
+ stopServer()
+ prepareShutdown()
+ logger.info("Teamscale Java Profiler successfully shut down.")
+ } catch (e: Exception) {
+ logger.error("Exception during profiler shutdown.", e)
+ } finally {
+ // Try to flush logging resources also in case of an exception during shutdown
+ PreMain.closeLoggingResources()
+ }
+ })
+ }
+
+ /** Stop the http server if it's running */
+ fun stopServer() {
+ options.getHttpServerPort()?.let {
+ try {
+ server.stop()
+ } catch (e: Exception) {
+ logger.error("Could not stop server so it is killed now.", e)
+ } finally {
+ server.destroy()
+ }
+ }
+ }
+
+ /** Called when the shutdown hook is triggered. */
+ protected open fun prepareShutdown() {
+ // Template method to be overridden by subclasses.
+ }
+
+ /**
+ * Dumps the current execution data, converts it, writes it to the output
+ * directory defined in [.options] and uploads it if an uploader is
+ * configured. Logs any errors, never throws an exception.
+ */
+ abstract fun dumpReport()
+}
\ No newline at end of file
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/AgentResource.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/AgentResource.kt
new file mode 100644
index 000000000..94447f400
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/AgentResource.kt
@@ -0,0 +1,41 @@
+package com.teamscale.jacoco.agent
+
+import javax.ws.rs.POST
+import javax.ws.rs.Path
+import javax.ws.rs.core.Response
+
+/**
+ * The resource of the Jersey + Jetty http server holding all the endpoints specific for the [Agent].
+ */
+@Path("/")
+class AgentResource : ResourceBase() {
+ /** Handles dumping a XML coverage report for coverage collected until now. */
+ @POST
+ @Path("/dump")
+ fun handleDump(): Response? {
+ logger.debug("Dumping report triggered via HTTP request")
+ agent.dumpReport()
+ return Response.noContent().build()
+ }
+
+ /** Handles resetting of coverage. */
+ @POST
+ @Path("/reset")
+ fun handleReset(): Response? {
+ logger.debug("Resetting coverage triggered via HTTP request")
+ agent.controller.reset()
+ return Response.noContent().build()
+ }
+
+ companion object {
+ private lateinit var agent: Agent
+
+ /**
+ * Static setter to inject the [Agent] to the resource.
+ */
+ fun setAgent(agent: Agent) {
+ Companion.agent = agent
+ agentBase = agent
+ }
+ }
+}
\ No newline at end of file
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/DelayedLogger.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/DelayedLogger.kt
new file mode 100644
index 000000000..d242ff79d
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/DelayedLogger.kt
@@ -0,0 +1,52 @@
+package com.teamscale.jacoco.agent
+
+import com.teamscale.report.util.ILogger
+import org.slf4j.Logger
+import java.util.function.Consumer
+
+/**
+ * A logger that buffers logs in memory and writes them to the actual logger at a later point. This is needed when stuff
+ * needs to be logged before the actual logging framework is initialized.
+ */
+class DelayedLogger : ILogger {
+ /** List of log actions that will be executed once the logger is initialized. */
+ private val logActions = mutableListOf Unit>()
+
+ override fun debug(message: String) {
+ logActions.add { debug(message) }
+ }
+
+ override fun info(message: String) {
+ logActions.add { info(message) }
+ }
+
+ override fun warn(message: String) {
+ logActions.add { warn(message) }
+ }
+
+ override fun warn(message: String, throwable: Throwable?) {
+ logActions.add { warn(message, throwable) }
+ }
+
+ override fun error(throwable: Throwable) {
+ logActions.add { error(throwable.message, throwable) }
+ }
+
+ override fun error(message: String, throwable: Throwable?) {
+ logActions.add { error(message, throwable) }
+ }
+
+ /**
+ * Logs an error and also writes the message to [System.err] to ensure the message is even logged in case
+ * setting up the logger itself fails for some reason (see TS-23151).
+ */
+ fun errorAndStdErr(message: String?, throwable: Throwable?) {
+ System.err.println(message)
+ logActions.add { error(message, throwable) }
+ }
+
+ /** Writes the logs to the given slf4j logger. */
+ fun logTo(logger: Logger) {
+ logActions.forEach { action -> action(logger) }
+ }
+}
\ No newline at end of file
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/GenericExceptionMapper.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/GenericExceptionMapper.kt
new file mode 100644
index 000000000..c7f0f9909
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/GenericExceptionMapper.kt
@@ -0,0 +1,18 @@
+package com.teamscale.jacoco.agent
+
+import javax.ws.rs.core.MediaType
+import javax.ws.rs.core.Response
+import javax.ws.rs.ext.ExceptionMapper
+import javax.ws.rs.ext.Provider
+
+/**
+ * Generates a [javax.ws.rs.core.Response] for an exception.
+ */
+@Provider
+class GenericExceptionMapper : ExceptionMapper {
+ override fun toResponse(e: Throwable?): Response =
+ Response.status(Response.Status.INTERNAL_SERVER_ERROR).apply {
+ type(MediaType.TEXT_PLAIN_TYPE)
+ entity("Message: ${e?.message}")
+ }.build()
+}
\ No newline at end of file
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/Helpers.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/Helpers.kt
new file mode 100644
index 000000000..d3dc43a97
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/Helpers.kt
@@ -0,0 +1,6 @@
+package com.teamscale.jacoco.agent
+
+import kotlin.time.measureTime
+
+fun benchmark(name: String, action: () -> Unit) =
+ measureTime { action() }.also { duration -> Main.logger.debug("$name took $duration") }
\ No newline at end of file
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/JacocoRuntimeController.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/JacocoRuntimeController.kt
new file mode 100644
index 000000000..0a76c0d3e
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/JacocoRuntimeController.kt
@@ -0,0 +1,118 @@
+package com.teamscale.jacoco.agent
+
+import com.teamscale.report.jacoco.dump.Dump
+import org.jacoco.agent.rt.IAgent
+import org.jacoco.core.data.ExecutionData
+import org.jacoco.core.data.ExecutionDataReader
+import org.jacoco.core.data.ExecutionDataStore
+import org.jacoco.core.data.IExecutionDataVisitor
+import org.jacoco.core.data.ISessionInfoVisitor
+import org.jacoco.core.data.SessionInfo
+import java.io.ByteArrayInputStream
+import java.io.File
+import java.io.FileOutputStream
+import java.io.IOException
+
+/**
+ * Wrapper around JaCoCo's [RT] runtime interface.
+ *
+ *
+ * Can be used if the calling code is run in the same JVM as the agent is attached to.
+ */
+class JacocoRuntimeController
+/** Constructor. */(
+ /** JaCoCo's [RT] agent instance */
+ private val agent: IAgent
+) {
+ /** Indicates a failed dump. */
+ class DumpException(message: String?, cause: Throwable?) : Exception(message, cause)
+
+ /**
+ * Dumps execution data and resets it.
+ *
+ * @throws DumpException if dumping fails. This should never happen in real life. Dumping should simply be retried
+ * later if this ever happens.
+ */
+ @Throws(DumpException::class)
+ fun dumpAndReset(): Dump {
+ val binaryData = agent.getExecutionData(true)
+
+ try {
+ ByteArrayInputStream(binaryData).use { inputStream ->
+ ExecutionDataReader(inputStream).apply {
+ val store = ExecutionDataStore()
+ setExecutionDataVisitor { store.put(it) }
+ val sessionInfoVisitor = SessionInfoVisitor()
+ setSessionInfoVisitor(sessionInfoVisitor)
+ read()
+ return Dump(sessionInfoVisitor.sessionInfo, store)
+ }
+ }
+ } catch (e: IOException) {
+ throw DumpException("should never happen for the ByteArrayInputStream", e)
+ }
+ }
+
+ /**
+ * Dumps execution data to the given file and resets it afterwards.
+ */
+ @Throws(IOException::class)
+ fun dumpToFileAndReset(file: File) {
+ val binaryData = agent.getExecutionData(true)
+
+ FileOutputStream(file, true).use { outputStream ->
+ outputStream.write(binaryData)
+ }
+ }
+
+
+ /**
+ * Dumps execution data to a file and resets it.
+ *
+ * @throws DumpException if dumping fails. This should never happen in real life. Dumping should simply be retried
+ * later if this ever happens.
+ */
+ @Throws(DumpException::class)
+ fun dump() {
+ try {
+ agent.dump(true)
+ } catch (e: IOException) {
+ throw DumpException(e.message, e)
+ }
+ }
+
+ /** Resets already collected coverage. */
+ fun reset() {
+ agent.reset()
+ }
+
+ var sessionId: String?
+ /** Returns the current sessionId. */
+ get() = agent.sessionId
+ /**
+ * Sets the current sessionId of the agent that can be used to identify which coverage is recorded from now on.
+ */
+ set(sessionId) {
+ agent.setSessionId(sessionId)
+ }
+
+ /** Unsets the session ID so that coverage collected from now on is not attributed to the previous test. */
+ fun resetSessionId() {
+ agent.sessionId = ""
+ }
+
+ /**
+ * Receives and stores a [org.jacoco.core.data.SessionInfo]. Has a fallback dummy session in case nothing is received.
+ */
+ private class SessionInfoVisitor : ISessionInfoVisitor {
+ /** The received session info or a dummy. */
+ var sessionInfo: SessionInfo = SessionInfo(
+ "dummysession", System.currentTimeMillis(), System.currentTimeMillis()
+ )
+
+ /** {@inheritDoc} */
+ override fun visitSessionInfo(info: SessionInfo) {
+ this.sessionInfo = info
+ }
+ }
+}
\ No newline at end of file
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/LenientCoverageTransformer.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/LenientCoverageTransformer.kt
new file mode 100644
index 000000000..dd9094e71
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/LenientCoverageTransformer.kt
@@ -0,0 +1,51 @@
+package com.teamscale.jacoco.agent
+
+import org.jacoco.agent.rt.internal_29a6edd.CoverageTransformer
+import org.jacoco.agent.rt.internal_29a6edd.IExceptionLogger
+import org.jacoco.agent.rt.internal_29a6edd.core.runtime.AgentOptions
+import org.jacoco.agent.rt.internal_29a6edd.core.runtime.IRuntime
+import org.slf4j.Logger
+import java.lang.instrument.IllegalClassFormatException
+import java.security.ProtectionDomain
+
+/**
+ * A class file transformer which delegates to the JaCoCo [org.jacoco.agent.rt.internal_29a6edd.CoverageTransformer] to do the actual instrumentation,
+ * but treats instrumentation errors e.g. due to unsupported class file versions more lenient by only logging them, but
+ * not bailing out completely. Those unsupported classes will not be instrumented and will therefore not be contained in
+ * the collected coverage report.
+ */
+class LenientCoverageTransformer(
+ runtime: IRuntime?,
+ options: AgentOptions,
+ private val logger: Logger
+) : CoverageTransformer(
+ runtime,
+ options,
+ // The coverage transformer only uses the logger to print an error when the instrumentation fails.
+ // We want to show our more specific error message instead, so we only log this for debugging at trace.
+ IExceptionLogger { logger.trace(it.message, it) }
+) {
+ override fun transform(
+ loader: ClassLoader?,
+ classname: String,
+ classBeingRedefined: Class<*>?,
+ protectionDomain: ProtectionDomain?,
+ classfileBuffer: ByteArray
+ ): ByteArray? {
+ try {
+ return super.transform(loader, classname, classBeingRedefined, protectionDomain, classfileBuffer)
+ } catch (e: IllegalClassFormatException) {
+ logger.error(
+ "Failed to instrument $classname. File will be skipped from instrumentation. " +
+ "No coverage will be collected for it. Exclude the file from the instrumentation or try " +
+ "updating the Teamscale Java Profiler if the file should actually be instrumented. (Cause: ${getRootCauseMessage(e)})"
+ )
+ return null
+ }
+ }
+
+ companion object {
+ private fun getRootCauseMessage(e: Throwable): String? =
+ e.cause?.let { getRootCauseMessage(it) } ?: e.message
+ }
+}
\ No newline at end of file
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/Main.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/Main.kt
new file mode 100644
index 000000000..e1efc8a12
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/Main.kt
@@ -0,0 +1,81 @@
+package com.teamscale.jacoco.agent
+
+import com.beust.jcommander.JCommander
+import com.beust.jcommander.Parameter
+import com.beust.jcommander.ParameterException
+import com.teamscale.client.StringUtils
+import com.teamscale.jacoco.agent.convert.ConvertCommand
+import com.teamscale.jacoco.agent.logging.LoggingUtils
+import com.teamscale.jacoco.agent.util.AgentUtils
+import org.jacoco.core.JaCoCo
+import org.slf4j.Logger
+import kotlin.system.exitProcess
+
+/** Provides a command line interface for interacting with JaCoCo. */
+object Main {
+ /** The logger. */
+ val logger: Logger = LoggingUtils.getLogger(this)
+
+ /** The default arguments that will always be parsed. */
+ private val defaultArguments = DefaultArguments()
+
+ /** The arguments for the one-time conversion process. */
+ private val command = ConvertCommand()
+
+ /** Entry point. */
+ @Throws(Exception::class)
+ @JvmStatic
+ fun main(args: Array) {
+ parseCommandLineAndRun(args)
+ }
+
+ /**
+ * Parses the given command line arguments. Exits the program or throws an exception if the arguments are not valid.
+ * Then runs the specified command.
+ */
+ @Throws(Exception::class)
+ private fun parseCommandLineAndRun(args: Array) {
+ val builder = createJCommanderBuilder()
+ val jCommander = builder.build()
+
+ try {
+ jCommander.parse(*args)
+ } catch (e: ParameterException) {
+ handleInvalidCommandLine(jCommander, e.message)
+ }
+
+ if (defaultArguments.help) {
+ println("Teamscale Java Profiler ${AgentUtils.VERSION} compiled against JaCoCo ${JaCoCo.VERSION}")
+ jCommander.usage()
+ return
+ }
+
+ val validator = command.validate()
+ if (!validator.isValid) {
+ handleInvalidCommandLine(jCommander, StringUtils.LINE_FEED + validator.errorMessage)
+ }
+
+ logger.info("Starting Teamscale Java Profiler ${AgentUtils.VERSION} compiled against JaCoCo ${JaCoCo.VERSION}")
+ command.run()
+ }
+
+ /** Shows an informative error and help message. Then exits the program. */
+ private fun handleInvalidCommandLine(jCommander: JCommander, message: String?) {
+ System.err.println("Invalid command line: $message${StringUtils.LINE_FEED}")
+ jCommander.usage()
+ exitProcess(1)
+ }
+
+ /** Creates a builder for a [com.beust.jcommander.JCommander] object. */
+ private fun createJCommanderBuilder() =
+ JCommander.newBuilder().programName(Main::class.java.getName())
+ .addObject(defaultArguments)
+ .addObject(command)
+
+ /** Default arguments that may always be provided. */
+ private class DefaultArguments {
+ /** Shows the help message. */
+ @Parameter(names = ["--help"], help = true, description = "Shows all available command line arguments.")
+ val help = false
+ }
+}
\ No newline at end of file
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/PreMain.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/PreMain.kt
new file mode 100644
index 000000000..37f157af1
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/PreMain.kt
@@ -0,0 +1,306 @@
+package com.teamscale.jacoco.agent
+
+import com.teamscale.client.FileSystemUtils
+import com.teamscale.client.HttpUtils
+import com.teamscale.client.StringUtils
+import com.teamscale.jacoco.agent.configuration.AgentOptionReceiveException
+import com.teamscale.jacoco.agent.logging.DebugLogDirectoryPropertyDefiner
+import com.teamscale.jacoco.agent.logging.LogDirectoryPropertyDefiner
+import com.teamscale.jacoco.agent.logging.LogToTeamscaleAppender
+import com.teamscale.jacoco.agent.logging.LoggingUtils
+import com.teamscale.jacoco.agent.options.AgentOptionParseException
+import com.teamscale.jacoco.agent.options.AgentOptions
+import com.teamscale.jacoco.agent.options.AgentOptionsParser
+import com.teamscale.jacoco.agent.options.FilePatternResolver
+import com.teamscale.jacoco.agent.options.JacocoAgentOptionsBuilder
+import com.teamscale.jacoco.agent.options.TeamscalePropertiesUtils
+import com.teamscale.jacoco.agent.testimpact.TestwiseCoverageAgent
+import com.teamscale.jacoco.agent.upload.UploaderException
+import com.teamscale.jacoco.agent.util.AgentUtils
+import com.teamscale.report.util.ILogger
+import java.io.IOException
+import java.lang.instrument.Instrumentation
+import java.lang.management.ManagementFactory
+import java.nio.file.Files
+import java.nio.file.Path
+import java.nio.file.Paths
+import kotlin.use
+
+/** Container class for the premain entry point for the agent. */
+object PreMain {
+ private lateinit var loggingResources: LoggingUtils.LoggingResources
+
+ /**
+ * System property that we use to prevent this agent from being attached to the same VM twice. This can happen if
+ * the agent is registered via multiple JVM environment variables and/or the command line at the same time.
+ */
+ private const val LOCKING_SYSTEM_PROPERTY = "TEAMSCALE_JAVA_PROFILER_ATTACHED"
+
+ /**
+ * Environment variable from which to read the config ID to use. This is an ID for a profiler configuration that is
+ * stored in Teamscale.
+ */
+ private const val CONFIG_ID_ENVIRONMENT_VARIABLE = "TEAMSCALE_JAVA_PROFILER_CONFIG_ID"
+
+ /** Environment variable from which to read the config file to use. */
+ private const val CONFIG_FILE_ENVIRONMENT_VARIABLE = "TEAMSCALE_JAVA_PROFILER_CONFIG_FILE"
+
+ /** Environment variable from which to read the Teamscale access token. */
+ private const val ACCESS_TOKEN_ENVIRONMENT_VARIABLE = "TEAMSCALE_ACCESS_TOKEN"
+
+ /**
+ * Entry point for the agent, called by the JVM.
+ */
+ @JvmStatic
+ @Throws(Exception::class)
+ fun premain(options: String?, instrumentation: Instrumentation?) {
+ if (System.getProperty(LOCKING_SYSTEM_PROPERTY) != null) return
+ System.setProperty(LOCKING_SYSTEM_PROPERTY, "true")
+
+ val environmentConfigId = System.getenv(CONFIG_ID_ENVIRONMENT_VARIABLE)
+ val environmentConfigFile = System.getenv(CONFIG_FILE_ENVIRONMENT_VARIABLE)
+ if (StringUtils.isEmpty(options) && environmentConfigId == null && environmentConfigFile == null) {
+ // profiler was registered globally, and no config was set explicitly by the user, thus ignore this process
+ // and don't profile anything
+ return
+ }
+
+ var agentOptions: AgentOptions? = null
+ try {
+ val parseResult = getAndApplyAgentOptions(
+ options, environmentConfigId, environmentConfigFile
+ )
+ agentOptions = parseResult.first
+
+ // After parsing everything and configuring logging, we now
+ // can throw the caught exceptions.
+ parseResult.second?.forEach { exception ->
+ throw exception
+ }
+ } catch (e: AgentOptionParseException) {
+ LoggingUtils.loggerContext.getLogger(PreMain::class.java).error(e.message, e)
+
+ // Flush logs to Teamscale, if configured.
+ closeLoggingResources()
+
+ // Unregister the profiler from Teamscale.
+ agentOptions?.configurationViaTeamscale?.unregisterProfiler()
+
+ throw e
+ } catch (_: AgentOptionReceiveException) {
+ // When Teamscale is not available, we don't want to fail hard to still allow for testing even if no
+ // coverage is collected (see TS-33237)
+ return
+ }
+
+ val logger = LoggingUtils.getLogger(Agent::class.java)
+
+ logger.info("Teamscale Java profiler version ${AgentUtils.VERSION}")
+ logger.info("Starting JaCoCo's agent")
+ val agentBuilder = JacocoAgentOptionsBuilder(agentOptions)
+ JaCoCoPreMain.premain(agentBuilder.createJacocoAgentOptions(), instrumentation, logger)
+
+ agentOptions.configurationViaTeamscale?.startHeartbeatThreadAndRegisterShutdownHook()
+ createAgent(agentOptions, instrumentation).registerShutdownHook()
+ }
+
+ @Throws(AgentOptionParseException::class, IOException::class, AgentOptionReceiveException::class)
+ private fun getAndApplyAgentOptions(
+ options: String?,
+ environmentConfigId: String?,
+ environmentConfigFile: String?
+ ): Pair?> {
+ val delayedLogger = DelayedLogger()
+ val javaAgents = ManagementFactory.getRuntimeMXBean().inputArguments
+ .filter { it.contains("-javaagent") }
+ // We allow multiple instances of the teamscale-jacoco-agent as we ensure with the #LOCKING_SYSTEM_PROPERTY to only use it once
+ val differentAgents = javaAgents.filter { !it.contains("teamscale-jacoco-agent.jar") }
+
+ if (!differentAgents.isEmpty()) {
+ delayedLogger.warn(
+ "Using multiple java agents could interfere with coverage recording: ${
+ differentAgents.joinToString()
+ }"
+ )
+ }
+ if (!javaAgents.first().contains("teamscale-jacoco-agent.jar")) {
+ delayedLogger.warn("For best results consider registering the Teamscale Java Profiler first.")
+ }
+
+ val credentials = TeamscalePropertiesUtils.parseCredentials()
+ if (credentials == null) {
+ // As many users still don't use the installer based setup, this log message will be shown in almost every log.
+ // We use a debug log, as this message can be confusing for customers that think a teamscale.properties file is synonymous with a config file.
+ delayedLogger.debug(
+ "No explicit teamscale.properties file given. Looking for Teamscale credentials in a config file or via a command line argument. This is expected unless the installer based setup was used."
+ )
+ }
+
+ val environmentAccessToken = System.getenv(ACCESS_TOKEN_ENVIRONMENT_VARIABLE)
+
+ val parseResult: Pair>
+ val agentOptions: AgentOptions
+ try {
+ parseResult = AgentOptionsParser.parse(
+ options, environmentConfigId, environmentConfigFile, credentials, environmentAccessToken, delayedLogger
+ )
+ agentOptions = parseResult.first
+ } catch (e: AgentOptionParseException) {
+ initializeFallbackLogging(options, delayedLogger).use { _ ->
+ delayedLogger.errorAndStdErr("Failed to parse agent options: ${e.message}", e)
+ attemptLogAndThrow(delayedLogger)
+ throw e
+ }
+ } catch (e: AgentOptionReceiveException) {
+ initializeFallbackLogging(options, delayedLogger).use { _ ->
+ delayedLogger.errorAndStdErr("${e.message} The application should start up normally, but NO coverage will be collected! Check the log file for details.", e)
+ attemptLogAndThrow(delayedLogger)
+ throw e
+ }
+ }
+
+ initializeLogging(agentOptions, delayedLogger)
+ val logger = LoggingUtils.getLogger(Agent::class.java)
+ delayedLogger.logTo(logger)
+ HttpUtils.setShouldValidateSsl(agentOptions.shouldValidateSsl())
+
+ return parseResult
+ }
+
+ private fun attemptLogAndThrow(delayedLogger: DelayedLogger) {
+ // We perform actual logging output after writing to console to
+ // ensure the console is reached even in case of logging issues
+ // (see TS-23151). We use the Agent class here (same as below)
+ val logger = LoggingUtils.getLogger(Agent::class.java)
+ delayedLogger.logTo(logger)
+ }
+
+ /** Initializes logging during [premain] and also logs the log directory. */
+ @Throws(IOException::class)
+ private fun initializeLogging(agentOptions: AgentOptions, logger: DelayedLogger) {
+ if (agentOptions.isDebugLogging) {
+ initializeDebugLogging(agentOptions, logger)
+ } else {
+ loggingResources = LoggingUtils.initializeLogging(agentOptions.getLoggingConfig())
+ logger.info("Logging to ${LogDirectoryPropertyDefiner().getPropertyValue()}")
+ }
+
+ if (agentOptions.teamscaleServerOptions.isConfiguredForServerConnection) {
+ if (LogToTeamscaleAppender.addTeamscaleAppenderTo(LoggingUtils.loggerContext, agentOptions)) {
+ logger.info("Logs are being forwarded to Teamscale at ${agentOptions.teamscaleServerOptions.url}")
+ }
+ }
+ }
+
+ /** Closes the opened logging contexts. */
+ fun closeLoggingResources() {
+ loggingResources.close()
+ }
+
+ /**
+ * Returns in instance of the agent that was configured. Either an agent with interval based line-coverage dump or
+ * the HTTP server is used.
+ */
+ @Throws(UploaderException::class, IOException::class)
+ private fun createAgent(
+ agentOptions: AgentOptions,
+ instrumentation: Instrumentation?
+ ): AgentBase = if (agentOptions.useTestwiseCoverageMode()) {
+ TestwiseCoverageAgent.create(agentOptions)
+ } else {
+ Agent(agentOptions, instrumentation)
+ }
+
+ /**
+ * Initializes debug logging during [.premain] and also logs the log directory if
+ * given.
+ */
+ private fun initializeDebugLogging(agentOptions: AgentOptions, logger: DelayedLogger) {
+ loggingResources = LoggingUtils.initializeDebugLogging(agentOptions.getDebugLogDirectory())
+ val logDirectory = Paths.get(DebugLogDirectoryPropertyDefiner().getPropertyValue())
+ if (FileSystemUtils.isValidPath(logDirectory.toString()) && Files.isWritable(logDirectory)) {
+ logger.info("Logging to $logDirectory")
+ } else {
+ logger.warn("Could not create $logDirectory. Logging to console only.")
+ }
+ }
+
+ /**
+ * Initializes fallback logging in case of an error during the parsing of the options to
+ * [premain] (see TS-23151). This tries to extract the logging configuration and use
+ * this and falls back to the default logger.
+ */
+ private fun initializeFallbackLogging(
+ premainOptions: String?,
+ delayedLogger: DelayedLogger
+ ): LoggingUtils.LoggingResources? {
+ if (premainOptions == null) {
+ return LoggingUtils.initializeDefaultLogging()
+ }
+ premainOptions
+ .split(",".toRegex())
+ .dropLastWhile { it.isEmpty() }
+ .forEach { optionPart ->
+ if (optionPart.startsWith(AgentOptionsParser.DEBUG + "=")) {
+ val value = optionPart.split("=".toRegex(), limit = 2)[1]
+ val debugDisabled = value.equals("false", ignoreCase = true)
+ val debugEnabled = value.equals("true", ignoreCase = true)
+ if (debugDisabled) return@forEach
+ var debugLogDirectory: Path? = null
+ if (!value.isEmpty() && !debugEnabled) {
+ debugLogDirectory = Paths.get(value)
+ }
+ return LoggingUtils.initializeDebugLogging(debugLogDirectory)
+ }
+ if (optionPart.startsWith(AgentOptionsParser.LOGGING_CONFIG_OPTION + "=")) {
+ return createFallbackLoggerFromConfig(
+ optionPart.split("=".toRegex(), limit = 2)[1],
+ delayedLogger
+ )
+ }
+
+ if (optionPart.startsWith(AgentOptionsParser.CONFIG_FILE_OPTION + "=")) {
+ val configFileValue = optionPart.split("=".toRegex(), limit = 2)[1]
+ var loggingConfigLine: String? = null
+ try {
+ val configFile = FilePatternResolver(delayedLogger).parsePath(
+ AgentOptionsParser.CONFIG_FILE_OPTION, configFileValue
+ ).toFile()
+ loggingConfigLine = FileSystemUtils.readLinesUTF8(configFile)
+ .firstOrNull { it.startsWith(AgentOptionsParser.LOGGING_CONFIG_OPTION + "=") }
+ } catch (e: IOException) {
+ delayedLogger.error("Failed to load configuration from $configFileValue: ${e.message}", e)
+ }
+ loggingConfigLine?.let { config ->
+ return createFallbackLoggerFromConfig(
+ config.split("=".toRegex(), limit = 2)[1], delayedLogger
+ )
+ }
+ }
+ }
+
+ return LoggingUtils.initializeDefaultLogging()
+ }
+
+ /** Creates a fallback logger using the given config file. */
+ private fun createFallbackLoggerFromConfig(
+ configLocation: String,
+ delayedLogger: ILogger
+ ): LoggingUtils.LoggingResources {
+ try {
+ return LoggingUtils.initializeLogging(
+ FilePatternResolver(delayedLogger).parsePath(
+ AgentOptionsParser.LOGGING_CONFIG_OPTION,
+ configLocation
+ )
+ )
+ } catch (e: IOException) {
+ val message = "Failed to load log configuration from location $configLocation: ${e.message}"
+ delayedLogger.error(message, e)
+ // output the message to console as well, as this might
+ // otherwise not make it to the user
+ System.err.println(message)
+ return LoggingUtils.initializeDefaultLogging()
+ }
+ }
+}
\ No newline at end of file
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/ResourceBase.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/ResourceBase.kt
new file mode 100644
index 000000000..fd4bb77cc
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/ResourceBase.kt
@@ -0,0 +1,141 @@
+package com.teamscale.jacoco.agent
+
+import com.teamscale.client.CommitDescriptor
+import com.teamscale.client.StringUtils
+import com.teamscale.client.TeamscaleServer
+import com.teamscale.jacoco.agent.logging.LoggingUtils
+import com.teamscale.report.testwise.model.RevisionInfo
+import org.jetbrains.annotations.Contract
+import org.slf4j.Logger
+import java.util.Optional
+import javax.ws.rs.BadRequestException
+import javax.ws.rs.GET
+import javax.ws.rs.PUT
+import javax.ws.rs.Path
+import javax.ws.rs.Produces
+import javax.ws.rs.core.MediaType
+import javax.ws.rs.core.Response
+
+/**
+ * The resource of the Jersey + Jetty http server holding all the endpoints specific for the [AgentBase].
+ */
+abstract class ResourceBase {
+ /** The logger. */
+ @JvmField
+ protected val logger: Logger = LoggingUtils.getLogger(this)
+
+ companion object {
+ /**
+ * The agentBase inject via [AgentResource.setAgent] or
+ * [com.teamscale.jacoco.agent.testimpact.TestwiseCoverageResource.setAgent].
+ */
+ @JvmStatic
+ protected lateinit var agentBase: AgentBase
+ }
+
+ @get:Path("/partition")
+ @get:GET
+ val partition: String
+ /** Returns the partition for the Teamscale upload. */
+ get() = agentBase.options.teamscaleServerOptions.partition.orEmpty()
+
+ @get:Path("/message")
+ @get:GET
+ val message: String
+ /** Returns the upload message for the Teamscale upload. */
+ get() = agentBase.options.teamscaleServerOptions.message.orEmpty()
+
+ @get:Produces(MediaType.APPLICATION_JSON)
+ @get:Path("/revision")
+ @get:GET
+ val revision: RevisionInfo
+ /** Returns revision information for the Teamscale upload. */
+ get() = revisionInfo
+
+ @get:Produces(MediaType.APPLICATION_JSON)
+ @get:Path("/commit")
+ @get:GET
+ val commit: RevisionInfo
+ /** Returns revision information for the Teamscale upload. */
+ get() = revisionInfo
+
+ /** Handles setting the partition name. */
+ @PUT
+ @Path("/partition")
+ fun setPartition(partitionString: String): Response {
+ val partition = StringUtils.removeDoubleQuotes(partitionString)
+ if (partition.isEmpty()) {
+ handleBadRequest("The new partition name is missing in the request body! Please add it as plain text.")
+ }
+
+ logger.debug("Changing partition name to $partition")
+ agentBase.dumpReport()
+ agentBase.controller.sessionId = partition
+ agentBase.options.teamscaleServerOptions.partition = partition
+ return Response.noContent().build()
+ }
+
+ /** Handles setting the upload message. */
+ @PUT
+ @Path("/message")
+ fun setMessage(messageString: String): Response {
+ val message = StringUtils.removeDoubleQuotes(messageString)
+ if (message.isEmpty()) {
+ handleBadRequest("The new message is missing in the request body! Please add it as plain text.")
+ }
+
+ agentBase.dumpReport()
+ logger.debug("Changing message to $message")
+ agentBase.options.teamscaleServerOptions.message = message
+
+ return Response.noContent().build()
+ }
+
+ /** Handles setting the revision. */
+ @PUT
+ @Path("/revision")
+ fun setRevision(revisionString: String): Response {
+ val revision = StringUtils.removeDoubleQuotes(revisionString)
+ if (revision.isEmpty()) {
+ handleBadRequest("The new revision name is missing in the request body! Please add it as plain text.")
+ }
+
+ agentBase.dumpReport()
+ logger.debug("Changing revision name to $revision")
+ agentBase.options.teamscaleServerOptions.revision = revision
+
+ return Response.noContent().build()
+ }
+
+ /** Handles setting the upload commit. */
+ @PUT
+ @Path("/commit")
+ fun setCommit(commitString: String): Response {
+ val commit = StringUtils.removeDoubleQuotes(commitString)
+ if (commit.isEmpty()) {
+ handleBadRequest("The new upload commit is missing in the request body! Please add it as plain text.")
+ }
+
+ agentBase.dumpReport()
+ agentBase.options.teamscaleServerOptions.commit = CommitDescriptor.parse(commit)
+
+ return Response.noContent().build()
+ }
+
+ private val revisionInfo: RevisionInfo
+ /** Returns revision information for the Teamscale upload. */
+ get() {
+ val server = agentBase.options.teamscaleServerOptions
+ return RevisionInfo(server.commit, server.revision)
+ }
+
+ /**
+ * Handles bad requests to the endpoints.
+ */
+ @Contract(value = "_ -> fail")
+ @Throws(BadRequestException::class)
+ protected fun handleBadRequest(message: String?) {
+ logger.error(message)
+ throw BadRequestException(message)
+ }
+}
\ No newline at end of file
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/commandline/ICommand.java b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commandline/ICommand.kt
similarity index 77%
rename from agent/src/main/java/com/teamscale/jacoco/agent/commandline/ICommand.java
rename to agent/src/main/kotlin/com/teamscale/jacoco/agent/commandline/ICommand.kt
index 4ce2fa697..5bbc9b7cd 100644
--- a/agent/src/main/java/com/teamscale/jacoco/agent/commandline/ICommand.java
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commandline/ICommand.kt
@@ -3,27 +3,26 @@
| Copyright (c) 2009-2017 CQSE GmbH |
| |
+-------------------------------------------------------------------------*/
-package com.teamscale.jacoco.agent.commandline;
+package com.teamscale.jacoco.agent.commandline
-import com.teamscale.jacoco.agent.options.AgentOptionParseException;
-
-import java.io.IOException;
+import com.teamscale.jacoco.agent.options.AgentOptionParseException
+import java.io.IOException
/**
* Interface for commands: argument parsing and execution.
*/
-public interface ICommand {
-
+interface ICommand {
/**
* Makes sure the arguments are valid. Must return all detected problems in the
* form of a user-visible message.
*/
- Validator validate() throws AgentOptionParseException, IOException;
+ @Throws(AgentOptionParseException::class, IOException::class)
+ fun validate(): Validator
/**
* Runs the implementation of the command. May throw an exception to indicate
* abnormal termination of the program.
*/
- void run() throws Exception;
-
+ @Throws(Exception::class)
+ fun run()
}
\ No newline at end of file
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/commandline/Validator.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commandline/Validator.kt
new file mode 100644
index 000000000..6f520dc7d
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commandline/Validator.kt
@@ -0,0 +1,61 @@
+/*-------------------------------------------------------------------------+
+| |
+| Copyright (c) 2009-2017 CQSE GmbH |
+| |
++-------------------------------------------------------------------------*/
+package com.teamscale.jacoco.agent.commandline
+
+import com.teamscale.client.StringUtils
+import com.teamscale.jacoco.agent.util.Assertions
+
+/**
+ * Helper class to allow for multiple validations to occur.
+ */
+class Validator {
+ /** The found validation problems in the form of error messages for the user. */
+ private val messages = mutableListOf()
+
+ /** Runs the given validation routine. */
+ fun ensure(validation: ExceptionBasedValidation) {
+ try {
+ validation.validate()
+ } catch (e: Exception) {
+ e.message?.let { messages.add(it) }
+ } catch (e: AssertionError) {
+ e.message?.let { messages.add(it) }
+ }
+ }
+
+ /**
+ * Interface for a validation routine that throws an exception when it fails.
+ */
+ fun interface ExceptionBasedValidation {
+ /**
+ * Throws an [Exception] or [AssertionError] if the validation fails.
+ */
+ @Throws(Exception::class, AssertionError::class)
+ fun validate()
+ }
+
+ /**
+ * Checks that the given condition is `true` or adds the given error message.
+ */
+ fun isTrue(condition: Boolean, message: String?) {
+ ensure { Assertions.isTrue(condition, message) }
+ }
+
+ /**
+ * Checks that the given condition is `false` or adds the given error message.
+ */
+ fun isFalse(condition: Boolean, message: String?) {
+ ensure { Assertions.isFalse(condition, message) }
+ }
+
+ val isValid: Boolean
+ /** Returns `true` if the validation succeeded. */
+ get() = messages.isEmpty()
+
+ val errorMessage: String
+ /** Returns an error message with all validation problems that were found. */
+ get() = "- ${messages.joinToString("${StringUtils.LINE_FEED}- ")}"
+}
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/CommitInfo.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/CommitInfo.kt
new file mode 100644
index 000000000..d66014435
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/CommitInfo.kt
@@ -0,0 +1,28 @@
+package com.teamscale.jacoco.agent.commit_resolution.git_properties
+
+import com.teamscale.client.CommitDescriptor
+import com.teamscale.client.StringUtils.isEmpty
+import java.util.*
+
+/** Hold information regarding a commit. */
+data class CommitInfo(
+ /** The revision information (git hash). */
+ @JvmField var revision: String?,
+ /** The commit descriptor. */
+ @JvmField var commit: CommitDescriptor?
+) {
+ /**
+ * If the commit property is set via the `teamscale.commit.branch` and `teamscale.commit.time`
+ * properties in a git.properties file, this should be preferred to the revision. For details see [TS-38561](https://cqse.atlassian.net/browse/TS-38561).
+ */
+ @JvmField
+ var preferCommitDescriptorOverRevision: Boolean = false
+
+ override fun toString() = "$commit/$revision"
+
+ /**
+ * Returns true if one of or both, revision and commit, are set
+ */
+ val isEmpty: Boolean
+ get() = revision.isNullOrEmpty() && commit == null
+}
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitMultiProjectPropertiesLocator.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitMultiProjectPropertiesLocator.kt
new file mode 100644
index 000000000..935272d8e
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitMultiProjectPropertiesLocator.kt
@@ -0,0 +1,95 @@
+package com.teamscale.jacoco.agent.commit_resolution.git_properties
+
+import com.teamscale.jacoco.agent.logging.LoggingUtils.getLogger
+import com.teamscale.jacoco.agent.upload.teamscale.DelayedTeamscaleMultiProjectUploader
+import com.teamscale.jacoco.agent.util.DaemonThreadFactory
+import org.jetbrains.annotations.VisibleForTesting
+import java.io.File
+import java.io.IOException
+import java.time.format.DateTimeFormatter
+import java.util.concurrent.Executor
+import java.util.concurrent.Executors
+
+/**
+ * Searches a Jar/War/Ear/... file for a git.properties file in order to enable upload for the commit described therein,
+ * e.g. to Teamscale, via a [DelayedTeamscaleMultiProjectUploader]. Specifically, this searches for the
+ * 'teamscale.project' property specified in each of the discovered 'git.properties' files.
+ */
+class GitMultiProjectPropertiesLocator(
+ private val uploader: DelayedTeamscaleMultiProjectUploader,
+ private val executor: Executor,
+ private val recursiveSearch: Boolean,
+ private val gitPropertiesCommitTimeFormat: DateTimeFormatter?
+) : IGitPropertiesLocator {
+ private val logger = getLogger(this)
+
+ constructor(
+ uploader: DelayedTeamscaleMultiProjectUploader,
+ recursiveSearch: Boolean,
+ gitPropertiesCommitTimeFormat: DateTimeFormatter?
+ ) : this(
+ uploader, Executors.newSingleThreadExecutor(
+ DaemonThreadFactory(
+ GitMultiProjectPropertiesLocator::class.java,
+ "git.properties Jar scanner thread"
+ )
+ ), recursiveSearch, gitPropertiesCommitTimeFormat
+ )
+
+ /**
+ * Asynchronously searches the given jar file for git.properties files and adds a corresponding uploader to the
+ * multi-project uploader.
+ */
+ override fun searchFileForGitPropertiesAsync(file: File, isJarFile: Boolean) {
+ executor.execute { searchFile(file, isJarFile) }
+ }
+
+ /**
+ * Synchronously searches the given jar file for git.properties files and adds a corresponding uploader to the
+ * multi-project uploader.
+ */
+ @VisibleForTesting
+ fun searchFile(file: File, isJarFile: Boolean) {
+ logger.debug("Searching file {} for multiple git.properties", file.toString())
+ try {
+ val projectAndCommits = GitPropertiesLocatorUtils.getProjectRevisionsFromGitProperties(
+ file, isJarFile, recursiveSearch, gitPropertiesCommitTimeFormat
+ )
+ if (projectAndCommits.isEmpty()) {
+ logger.debug("No git.properties file found in {}", file)
+ return
+ }
+
+ projectAndCommits.forEach { projectAndCommit ->
+ // this code only runs when 'teamscale-project' is not given via the agent properties,
+ // i.e., a multi-project upload is being attempted.
+ // Therefore, we expect to find both the project (teamscale.project) and the revision
+ // (git.commit.id) in the git.properties file.
+ if (projectAndCommit.project == null || projectAndCommit.commitInfo == null) {
+ logger.debug(
+ "Found inconsistent git.properties file: the git.properties file in {} either does not specify the" +
+ " Teamscale project ({}) property, or does not specify the commit " +
+ "({}, {} + {}, or {} + {})." +
+ " Will skip this git.properties file and try to continue with the other ones that were found during discovery.",
+ file, GitPropertiesLocatorUtils.GIT_PROPERTIES_TEAMSCALE_PROJECT,
+ GitPropertiesLocatorUtils.GIT_PROPERTIES_GIT_COMMIT_ID,
+ GitPropertiesLocatorUtils.GIT_PROPERTIES_GIT_BRANCH,
+ GitPropertiesLocatorUtils.GIT_PROPERTIES_GIT_COMMIT_TIME,
+ GitPropertiesLocatorUtils.GIT_PROPERTIES_TEAMSCALE_COMMIT_BRANCH,
+ GitPropertiesLocatorUtils.GIT_PROPERTIES_TEAMSCALE_COMMIT_TIME
+ )
+ return@forEach
+ }
+ logger.debug(
+ "Found git.properties file in {} and found Teamscale project {} and revision {}", file,
+ projectAndCommit.project, projectAndCommit.commitInfo
+ )
+ uploader.addTeamscaleProjectAndCommit(file, projectAndCommit)
+ }
+ } catch (e: IOException) {
+ logger.error("Error during asynchronous search for git.properties in {}", file, e)
+ } catch (e: InvalidGitPropertiesException) {
+ logger.error("Error during asynchronous search for git.properties in {}", file, e)
+ }
+ }
+}
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitPropertiesLocatingTransformer.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitPropertiesLocatingTransformer.kt
new file mode 100644
index 000000000..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..15831af8f
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitPropertiesLocatorUtils.kt
@@ -0,0 +1,506 @@
+package com.teamscale.jacoco.agent.commit_resolution.git_properties
+
+import com.teamscale.client.CommitDescriptor
+import com.teamscale.client.FileSystemUtils.listFilesRecursively
+import com.teamscale.client.StringUtils.endsWithOneOf
+import com.teamscale.client.StringUtils.isEmpty
+import com.teamscale.jacoco.agent.options.ProjectAndCommit
+import com.teamscale.report.util.BashFileSkippingInputStream
+import java.io.File
+import java.io.IOException
+import java.lang.reflect.InvocationTargetException
+import java.net.URISyntaxException
+import java.net.URL
+import java.nio.file.Files
+import java.nio.file.Paths
+import java.time.ZonedDateTime
+import java.time.format.DateTimeFormatter
+import java.time.format.DateTimeFormatterBuilder
+import java.time.format.DateTimeParseException
+import java.util.*
+import java.util.jar.JarEntry
+import java.util.jar.JarInputStream
+import java.util.regex.Pattern
+
+/** Utility methods to extract certain properties from git.properties files in archives and folders. */
+object GitPropertiesLocatorUtils {
+ /** Name of the git.properties file. */
+ const val GIT_PROPERTIES_FILE_NAME: String = "git.properties"
+
+ /** The git.properties key that holds the commit time. */
+ const val GIT_PROPERTIES_GIT_COMMIT_TIME: String = "git.commit.time"
+
+ /** The git.properties key that holds the commit branch. */
+ const val GIT_PROPERTIES_GIT_BRANCH: String = "git.branch"
+
+ /** The git.properties key that holds the commit hash. */
+ const val GIT_PROPERTIES_GIT_COMMIT_ID: String = "git.commit.id"
+
+ /**
+ * Alternative git.properties key that might also hold the commit hash, depending on the Maven git-commit-id plugin
+ * configuration.
+ */
+ const val GIT_PROPERTIES_GIT_COMMIT_ID_FULL: String = "git.commit.id.full"
+
+ /**
+ * You can provide a teamscale timestamp in git.properties files to overwrite the revision. See [TS-38561](https://cqse.atlassian.net/browse/TS-38561).
+ */
+ const val GIT_PROPERTIES_TEAMSCALE_COMMIT_BRANCH: String = "teamscale.commit.branch"
+
+ /**
+ * You can provide a teamscale timestamp in git.properties files to overwrite the revision. See [TS-38561](https://cqse.atlassian.net/browse/TS-38561).
+ */
+ const val GIT_PROPERTIES_TEAMSCALE_COMMIT_TIME: String = "teamscale.commit.time"
+
+ /** The git.properties key that holds the Teamscale project name. */
+ const val GIT_PROPERTIES_TEAMSCALE_PROJECT: String = "teamscale.project"
+
+ /** Matches the path to the jar file in a jar:file: URL in regex group 1. */
+ private val JAR_URL_REGEX: Pattern = Pattern.compile(
+ "jar:(?:file|nested):(.*?)!.*",
+ Pattern.CASE_INSENSITIVE
+ )
+
+ private val NESTED_JAR_REGEX: Pattern = Pattern.compile(
+ "[jwea]ar:file:(.*?)\\*(.*)",
+ Pattern.CASE_INSENSITIVE
+ )
+
+ /**
+ * Defined in [GitCommitIdMojo](https://github.com/git-commit-id/git-commit-id-maven-plugin/blob/ac05b16dfdcc2aebfa45ad3af4acf1254accffa3/src/main/java/pl/project13/maven/git/GitCommitIdMojo.java#L522)
+ */
+ private const val GIT_PROPERTIES_DEFAULT_MAVEN_DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ssXXX"
+
+ /**
+ * Defined in [GitPropertiesPlugin](https://github.com/n0mer/gradle-git-properties/blob/bb1c3353bb570495644b6c6c75e211296a8354fc/src/main/groovy/com/gorylenko/GitPropertiesPlugin.groovy#L68)
+ */
+ private const val GIT_PROPERTIES_DEFAULT_GRADLE_DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ssZ"
+
+ /**
+ * Reads the git SHA1 and branch and timestamp from the given jar file's git.properties and builds a commit
+ * descriptor out of it. If no git.properties file can be found, returns null.
+ *
+ * @throws IOException If reading the jar file fails.
+ * @throws InvalidGitPropertiesException If a git.properties file is found but it is malformed.
+ */
+ @JvmStatic
+ @Throws(IOException::class, InvalidGitPropertiesException::class)
+ fun getCommitInfoFromGitProperties(
+ file: File,
+ isJarFile: Boolean,
+ recursiveSearch: Boolean,
+ gitPropertiesCommitTimeFormat: DateTimeFormatter?
+ ) = findGitPropertiesInFile(file, isJarFile, recursiveSearch).map { entryWithProperties ->
+ getCommitInfoFromGitProperties(
+ entryWithProperties.second, entryWithProperties.first, file,
+ gitPropertiesCommitTimeFormat
+ )
+ }
+
+ /**
+ * Tries to extract a file system path to a search root for the git.properties search. A search root is either a
+ * file system folder or a Jar file. If no such path can be extracted, returns null.
+ *
+ * @throws URISyntaxException under certain circumstances if parsing the URL fails. This should be treated the same
+ * as a null search result but the exception is preserved so it can be logged.
+ */
+ @JvmStatic
+ @Throws(
+ URISyntaxException::class,
+ IOException::class,
+ NoSuchMethodException::class,
+ IllegalAccessException::class,
+ InvocationTargetException::class
+ )
+ fun extractGitPropertiesSearchRoot(
+ jarOrClassFolderUrl: URL
+ ): Pair? {
+ val protocol = jarOrClassFolderUrl.protocol.lowercase(Locale.getDefault())
+ when (protocol) {
+ "file" -> {
+ val jarOrClassFolderFile = File(jarOrClassFolderUrl.toURI())
+ if (jarOrClassFolderFile.isDirectory() || isJarLikeFile(jarOrClassFolderUrl.path)) {
+ return jarOrClassFolderFile to !jarOrClassFolderFile.isDirectory()
+ }
+ }
+
+ "jar" -> {
+ // Used e.g. by Spring Boot. Example: jar:file:/home/k/demo.jar!/BOOT-INF/classes!/
+ val jarMatcher = JAR_URL_REGEX.matcher(jarOrClassFolderUrl.toString())
+ if (jarMatcher.matches()) {
+ return File(jarMatcher.group(1)) to true
+ }
+ // Used by some web applications and potentially fat jars.
+ // Example: war:file:/Users/example/apache-tomcat/webapps/demo.war*/WEB-INF/lib/demoLib-1.0-SNAPSHOT.jar
+ val nestedMatcher = NESTED_JAR_REGEX.matcher(jarOrClassFolderUrl.toString())
+ if (nestedMatcher.matches()) {
+ return File(nestedMatcher.group(1)) to true
+ }
+ }
+
+ "war", "ear" -> {
+ val nestedMatcher = NESTED_JAR_REGEX.matcher(jarOrClassFolderUrl.toString())
+ if (nestedMatcher.matches()) {
+ return File(nestedMatcher.group(1)) to true
+ }
+ }
+
+ "vfs" -> return getVfsContentFolder(jarOrClassFolderUrl)
+ else -> return null
+ }
+ return null
+ }
+
+ /**
+ * VFS (Virtual File System) protocol is used by JBoss EAP and Wildfly. Example of an URL:
+ * vfs:/content/helloworld.war/WEB-INF/classes
+ */
+ @Throws(
+ IOException::class,
+ NoSuchMethodException::class,
+ IllegalAccessException::class,
+ InvocationTargetException::class
+ )
+ private fun getVfsContentFolder(
+ jarOrClassFolderUrl: URL
+ ): Pair {
+ // we obtain the URL of a specific class file as input, e.g.,
+ // vfs:/content/helloworld.war/WEB-INF/classes
+ // Next, we try to extract the artefact URL from it, e.g., vfs:/content/helloworld.war
+ val artefactUrl = extractArtefactUrl(jarOrClassFolderUrl)
+
+ val virtualFile = URL(artefactUrl).openConnection().getContent()
+ // 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..170a60ae1
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitSingleProjectPropertiesLocator.kt
@@ -0,0 +1,104 @@
+package com.teamscale.jacoco.agent.commit_resolution.git_properties
+
+import com.teamscale.jacoco.agent.logging.LoggingUtils.getLogger
+import com.teamscale.jacoco.agent.upload.delay.DelayedUploader
+import com.teamscale.jacoco.agent.util.DaemonThreadFactory
+import java.io.File
+import java.io.IOException
+import java.time.format.DateTimeFormatter
+import java.util.concurrent.Executor
+import java.util.concurrent.Executors
+
+/**
+ * Searches a Jar/War/Ear/... file for a git.properties file in order to enable upload for the commit described therein,
+ * e.g. to Teamscale, via a [DelayedUploader].
+ */
+class GitSingleProjectPropertiesLocator(
+ private val uploader: DelayedUploader,
+ private val dataExtractor: DataExtractor,
+ private val executor: Executor,
+ private val recursiveSearch: Boolean,
+ private val gitPropertiesCommitTimeFormat: DateTimeFormatter?
+) : IGitPropertiesLocator {
+ private val logger = getLogger(this)
+ private var foundData: T? = null
+ private var jarFileWithGitProperties: File? = null
+
+ constructor(
+ uploader: DelayedUploader,
+ dataExtractor: DataExtractor,
+ recursiveSearch: Boolean,
+ gitPropertiesCommitTimeFormat: DateTimeFormatter?
+ ) : this(
+ uploader, dataExtractor, Executors.newSingleThreadExecutor(
+ DaemonThreadFactory(
+ GitSingleProjectPropertiesLocator::class.java,
+ "git.properties Jar scanner thread"
+ )
+ ), recursiveSearch, gitPropertiesCommitTimeFormat
+ )
+
+ /**
+ * Asynchronously searches the given jar file for a git.properties file.
+ */
+ override fun searchFileForGitPropertiesAsync(file: File, isJarFile: Boolean) {
+ executor.execute { searchFile(file, isJarFile) }
+ }
+
+ private fun searchFile(file: File, isJarFile: Boolean) {
+ logger.debug("Searching jar file {} for a single git.properties", file)
+ try {
+ val data = dataExtractor.extractData(file, isJarFile, recursiveSearch, gitPropertiesCommitTimeFormat)
+ if (data.isEmpty()) {
+ logger.debug("No git.properties files found in {}", file.toString())
+ return
+ }
+ if (data.size > 1) {
+ logger.warn(
+ "Multiple git.properties files found in {}", file.toString() +
+ ". Using the first one: " + data.first()
+ )
+ }
+ val dataEntry = data.first()
+
+ if (foundData != null) {
+ if (foundData != dataEntry) {
+ logger.warn(
+ "Found inconsistent git.properties files: {} contained data {} while {} contained {}." +
+ " Please ensure that all git.properties files of your application are consistent." +
+ " Otherwise, you may" +
+ " be uploading to the wrong project/commit which will result in incorrect coverage data" +
+ " displayed in Teamscale. If you cannot fix the inconsistency, you can manually" +
+ " specify a Jar/War/Ear/... file from which to read the correct git.properties" +
+ " file with the agent's teamscale-git-properties-jar parameter.",
+ jarFileWithGitProperties, foundData, file, data
+ )
+ }
+ return
+ }
+
+ logger.debug(
+ "Found git.properties file in {} and found commit descriptor {}", file.toString(),
+ dataEntry
+ )
+ foundData = dataEntry
+ jarFileWithGitProperties = file
+ uploader.setCommitAndTriggerAsynchronousUpload(dataEntry)
+ } catch (e: IOException) {
+ logger.error("Error during asynchronous search for git.properties in {}", file.toString(), e)
+ } catch (e: InvalidGitPropertiesException) {
+ logger.error("Error during asynchronous search for git.properties in {}", file.toString(), e)
+ }
+ }
+
+ /** Functional interface for data extraction from a jar file. */
+ fun interface DataExtractor {
+ /** Extracts data from the JAR. */
+ @Throws(IOException::class, InvalidGitPropertiesException::class)
+ fun extractData(
+ file: File?, isJarFile: Boolean,
+ recursiveSearch: Boolean,
+ gitPropertiesCommitTimeFormat: DateTimeFormatter?
+ ): MutableList
+ }
+}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/IGitPropertiesLocator.java b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/IGitPropertiesLocator.kt
similarity index 61%
rename from agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/IGitPropertiesLocator.java
rename to agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/IGitPropertiesLocator.kt
index 0fc60c7ce..30f724b69 100644
--- a/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/IGitPropertiesLocator.java
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/IGitPropertiesLocator.kt
@@ -1,13 +1,12 @@
-package com.teamscale.jacoco.agent.commit_resolution.git_properties;
+package com.teamscale.jacoco.agent.commit_resolution.git_properties
-import java.io.File;
-
-/** Interface for the locator classes that search files (e.g., a JAR) for git.properties files containing certain properties. */
-public interface IGitPropertiesLocator {
+import java.io.File
+/** Interface for the locator classes that search files (e.g., a JAR) for git.properties files containing certain properties. */
+interface IGitPropertiesLocator {
/**
* Searches the file for the git.properties file containing certain properties. The boolean flag indicates whether the
* searched file is a JAR file or a plain directory.
*/
- void searchFileForGitPropertiesAsync(File file, boolean isJarFile);
+ fun searchFileForGitPropertiesAsync(file: File, isJarFile: Boolean)
}
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/InvalidGitPropertiesException.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/InvalidGitPropertiesException.kt
new file mode 100644
index 000000000..bbfb2e59e
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/InvalidGitPropertiesException.kt
@@ -0,0 +1,9 @@
+package com.teamscale.jacoco.agent.commit_resolution.git_properties
+
+/**
+ * Thrown in case a git.properties file is found but it is malformed.
+ */
+class InvalidGitPropertiesException : Exception {
+ internal constructor(s: String, throwable: Throwable?) : super(s, throwable)
+ constructor(s: String) : super(s)
+}
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/sapnwdi/NwdiMarkerClassLocatingTransformer.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/sapnwdi/NwdiMarkerClassLocatingTransformer.kt
new file mode 100644
index 000000000..7c966fb8e
--- /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: MutableCollection
+) : 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..f20312b13
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/configuration/ConfigurationViaTeamscale.kt
@@ -0,0 +1,162 @@
+package com.teamscale.jacoco.agent.configuration
+
+import com.fasterxml.jackson.core.JsonProcessingException
+import com.teamscale.client.ITeamscaleService
+import com.teamscale.client.JsonUtils
+import com.teamscale.client.ProcessInformation
+import com.teamscale.client.ProfilerConfiguration
+import com.teamscale.client.ProfilerInfo
+import com.teamscale.client.ProfilerRegistration
+import com.teamscale.client.TeamscaleServiceGenerator
+import com.teamscale.jacoco.agent.logging.LoggingUtils
+import com.teamscale.jacoco.agent.util.AgentUtils
+import com.teamscale.report.util.ILogger
+import okhttp3.HttpUrl
+import okhttp3.ResponseBody
+import retrofit2.Response
+import java.io.IOException
+import java.time.Duration
+import java.util.concurrent.Executors
+import java.util.concurrent.ThreadFactory
+import java.util.concurrent.TimeUnit
+
+/**
+ * Responsible for holding the configuration retrieved from Teamscale and sending regular heartbeat events to
+ * keep the profiler information in Teamscale up to date.
+ */
+class ConfigurationViaTeamscale(
+ private val teamscaleClient: ITeamscaleService,
+ profilerRegistration: ProfilerRegistration,
+ processInformation: ProcessInformation
+) {
+ /**
+ * The UUID that Teamscale assigned to this instance of the profiler during the registration. This ID needs to be
+ * used when communicating with Teamscale.
+ */
+ @JvmField
+ val profilerId = profilerRegistration.profilerId
+
+ private val profilerInfo = ProfilerInfo(processInformation, profilerRegistration.profilerConfiguration)
+
+ /** Returns the profiler configuration retrieved from Teamscale. */
+ val profilerConfiguration: ProfilerConfiguration?
+ get() = profilerInfo.profilerConfiguration
+
+ /**
+ * Starts a heartbeat thread and registers a shutdown hook.
+ *
+ *
+ * This spawns a new thread every minute which sends a heartbeat to Teamscale. It also registers a shutdown hook
+ * that unregisters the profiler from Teamscale.
+ */
+ fun startHeartbeatThreadAndRegisterShutdownHook() {
+ val executor = Executors.newSingleThreadScheduledExecutor { runnable ->
+ val thread = Thread(runnable)
+ thread.setDaemon(true)
+ thread
+ }
+
+ executor.scheduleAtFixedRate({ sendHeartbeat() }, 1, 1, TimeUnit.MINUTES)
+
+ Runtime.getRuntime().addShutdownHook(Thread {
+ executor.shutdownNow()
+ unregisterProfiler()
+ })
+ }
+
+ private fun sendHeartbeat() {
+ try {
+ val response = teamscaleClient.sendHeartbeat(profilerId!!, profilerInfo).execute()
+ if (!response.isSuccessful) {
+ LoggingUtils.getLogger(this)
+ .error("Failed to send heartbeat. Teamscale responded with: ${response.errorBody()?.string()}")
+ }
+ } catch (e: IOException) {
+ LoggingUtils.getLogger(this).error("Failed to send heartbeat to Teamscale!", e)
+ }
+ }
+
+ /** Unregisters the profiler in Teamscale (marks it as shut down). */
+ fun unregisterProfiler() {
+ try {
+ var response = teamscaleClient.unregisterProfiler(profilerId!!).execute()
+ if (response.code() == 405) {
+ response = teamscaleClient.unregisterProfilerLegacy(profilerId).execute()
+ }
+ if (!response.isSuccessful) {
+ LoggingUtils.getLogger(this)
+ .error("Failed to unregister profiler. Teamscale responded with: ${response.errorBody()?.string()}")
+ }
+ } catch (e: IOException) {
+ LoggingUtils.getLogger(this).error("Failed to unregister profiler!", e)
+ }
+ }
+
+ companion object {
+ /**
+ * Two minute timeout. This is quite high to account for an eventual high load on the Teamscale server. This is a
+ * tradeoff between fast application startup and potentially missing test coverage.
+ */
+ private val LONG_TIMEOUT: Duration = Duration.ofMinutes(2)
+
+ /**
+ * Tries to retrieve the profiler configuration from Teamscale. In case retrieval fails the method throws a
+ * [AgentOptionReceiveException].
+ */
+ @JvmStatic
+ @Throws(AgentOptionReceiveException::class)
+ fun retrieve(
+ logger: ILogger,
+ configurationId: String?,
+ url: HttpUrl,
+ userName: String,
+ userAccessToken: String
+ ): ConfigurationViaTeamscale {
+ val teamscaleClient = TeamscaleServiceGenerator
+ .createService(url, userName, userAccessToken, AgentUtils.USER_AGENT, LONG_TIMEOUT, LONG_TIMEOUT)
+ try {
+ val processInformation = ProcessInformationRetriever(logger).processInformation
+ val response = teamscaleClient.registerProfiler(
+ configurationId,
+ processInformation
+ ).execute()
+ if (!response.isSuccessful) {
+ throw AgentOptionReceiveException(
+ "Failed to retrieve profiler configuration from Teamscale due to failed request. Http status: ${response.code()} Body: ${response.errorBody()?.string()}"
+ )
+ }
+
+ val body = response.body()
+ return parseProfilerRegistration(body!!, response, teamscaleClient, processInformation)
+ } catch (e: IOException) {
+ // we include the causing error message in this exception's message since this causes it to be printed
+ // to stderr which is much more helpful than just saying "something didn't work"
+ throw AgentOptionReceiveException(
+ "Failed to retrieve profiler configuration from Teamscale due to network error: ${
+ LoggingUtils.getStackTraceAsString(e)
+ }", e
+ )
+ }
+ }
+
+ @Throws(AgentOptionReceiveException::class, IOException::class)
+ private fun parseProfilerRegistration(
+ body: ResponseBody,
+ response: Response,
+ teamscaleClient: ITeamscaleService,
+ processInformation: ProcessInformation
+ ): ConfigurationViaTeamscale {
+ // We may only call this once
+ val bodyString = body.string()
+ try {
+ val registration = JsonUtils.deserialize(bodyString)
+ return ConfigurationViaTeamscale(teamscaleClient, registration, processInformation)
+ } catch (e: JsonProcessingException) {
+ throw AgentOptionReceiveException(
+ "Failed to retrieve profiler configuration from Teamscale due to invalid JSON. HTTP code: " + response.code() + " Response: " + bodyString,
+ e
+ )
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/configuration/ProcessInformationRetriever.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/configuration/ProcessInformationRetriever.kt
new file mode 100644
index 000000000..e4692acdd
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/configuration/ProcessInformationRetriever.kt
@@ -0,0 +1,53 @@
+package com.teamscale.jacoco.agent.configuration
+
+import com.teamscale.client.ProcessInformation
+import com.teamscale.report.util.ILogger
+import java.lang.management.ManagementFactory
+import java.net.InetAddress
+import java.net.UnknownHostException
+
+/**
+ * Is responsible for retrieving process information such as the host name and process ID.
+ */
+class ProcessInformationRetriever(private val logger: ILogger) {
+ /**
+ * Retrieves the process information, including the host name and process ID.
+ */
+ val processInformation: ProcessInformation
+ get() = ProcessInformation(hostName, pID, System.currentTimeMillis())
+
+ /**
+ * Retrieves the host name of the local machine.
+ */
+ private val hostName: String
+ get() {
+ try {
+ return InetAddress.getLocalHost().hostName
+ } catch (e: UnknownHostException) {
+ logger.error("Failed to determine hostname!", e)
+ return ""
+ }
+ }
+
+ /**
+ * Returns a string that *probably* contains the PID.
+ *
+ * On Java 9 there is an API to get the PID. But since we support Java 8, we may fall back to an undocumented API
+ * that at least contains the PID in most JVMs.
+ *
+ * See [This StackOverflow question](https://stackoverflow.com/questions/35842/how-can-a-java-program-get-its-own-process-id)
+ */
+ companion object {
+ val pID: String
+ get() {
+ try {
+ val processHandleClass = Class.forName("java.lang.ProcessHandle")
+ val processHandle = processHandleClass.getMethod("current").invoke(null)
+ val pid = processHandleClass.getMethod("pid").invoke(processHandle) as Long
+ return pid.toString()
+ } catch (_: ReflectiveOperationException) {
+ return ManagementFactory.getRuntimeMXBean().name
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/convert/ConvertCommand.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/convert/ConvertCommand.kt
new file mode 100644
index 000000000..2183de6f5
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/convert/ConvertCommand.kt
@@ -0,0 +1,156 @@
+/*-------------------------------------------------------------------------+
+| |
+| Copyright (c) 2009-2017 CQSE GmbH |
+| |
++-------------------------------------------------------------------------*/
+package com.teamscale.jacoco.agent.convert
+
+import com.beust.jcommander.Parameter
+import com.beust.jcommander.Parameters
+import com.teamscale.client.FileSystemUtils.ensureDirectoryExists
+import com.teamscale.client.StringUtils.isEmpty
+import com.teamscale.jacoco.agent.commandline.ICommand
+import com.teamscale.jacoco.agent.commandline.Validator
+import com.teamscale.jacoco.agent.options.ClasspathUtils
+import com.teamscale.jacoco.agent.options.FilePatternResolver
+import com.teamscale.jacoco.agent.util.Assertions
+import com.teamscale.report.EDuplicateClassFileBehavior
+import com.teamscale.report.util.CommandLineLogger
+import java.io.File
+import java.io.IOException
+
+/**
+ * Encapsulates all command line options for the convert command for parsing with [JCommander].
+ */
+@Parameters(
+ commandNames = ["convert"], commandDescription = "Converts a binary .exec coverage file to XML. " +
+ "Note that the XML report will only contain source file coverage information, but no class coverage."
+)
+class ConvertCommand : ICommand {
+ /** The directories and/or zips that contain all class files being profiled. */
+ @JvmField
+ @Parameter(
+ names = ["--class-dir", "--jar", "-c"], required = true, description = (""
+ + "The directories or zip/ear/jar/war/... files that contain the compiled Java classes being profiled."
+ + " Searches recursively, including inside zips. You may also supply a *.txt file with one path per line.")
+ )
+ var classDirectoriesOrZips = mutableListOf()
+
+ /**
+ * Wildcard include patterns to apply during JaCoCo's traversal of class files.
+ */
+ @Parameter(
+ names = ["--includes"], description = (""
+ + "Wildcard include patterns to apply to all found class file locations during JaCoCo's traversal of class files."
+ + " Note that zip contents are separated from zip files with @ and that you can filter only"
+ + " class files, not intermediate folders/zips. Use with great care as missing class files"
+ + " lead to broken coverage files! Turn on debug logging to see which locations are being filtered."
+ + " Defaults to no filtering. Excludes overrule includes.")
+ )
+ var locationIncludeFilters = mutableListOf()
+
+ /**
+ * Wildcard exclude patterns to apply during JaCoCo's traversal of class files.
+ */
+ @Parameter(
+ names = ["--excludes", "-e"], description = (""
+ + "Wildcard exclude patterns to apply to all found class file locations during JaCoCo's traversal of class files."
+ + " Note that zip contents are separated from zip files with @ and that you can filter only"
+ + " class files, not intermediate folders/zips. Use with great care as missing class files"
+ + " lead to broken coverage files! Turn on debug logging to see which locations are being filtered."
+ + " Defaults to no filtering. Excludes overrule includes.")
+ )
+ var locationExcludeFilters = mutableListOf()
+
+ /** The directory to write the XML traces to. */
+ @JvmField
+ @Parameter(
+ names = ["--in", "-i"], required = true, description = ("" + "The binary .exec file(s), test details and " +
+ "test executions to read. Can be a single file or a directory that is recursively scanned for relevant files.")
+ )
+ var inputFiles = mutableListOf()
+
+ /** The directory to write the XML traces to. */
+ @JvmField
+ @Parameter(
+ names = ["--out", "-o"], required = true, description = (""
+ + "The file to write the generated XML report to.")
+ )
+ var outputFile = ""
+
+ /** Whether to ignore duplicate, non-identical class files. */
+ @Parameter(
+ names = ["--duplicates", "-d"], arity = 1, description = (""
+ + "Whether to ignore duplicate, non-identical class files."
+ + " This is discouraged and may result in incorrect coverage files. Defaults to WARN. " +
+ "Options are FAIL, WARN and IGNORE.")
+ )
+ var duplicateClassFileBehavior = EDuplicateClassFileBehavior.WARN
+
+ /** Whether to ignore uncovered class files. */
+ @Parameter(
+ names = ["--ignore-uncovered-classes"], required = false, arity = 1, description = (""
+ + "Whether to ignore uncovered classes."
+ + " These classes will not be part of the XML report at all, making it considerably smaller in some cases. Defaults to false.")
+ )
+ var shouldIgnoreUncoveredClasses = false
+
+ /** Whether testwise coverage or jacoco coverage should be generated. */
+ @Parameter(
+ names = ["--testwise-coverage", "-t"], required = false, arity = 0, description = "Whether testwise " +
+ "coverage or jacoco coverage should be generated."
+ )
+ var shouldGenerateTestwiseCoverage = false
+
+ /** After how many tests testwise coverage should be split into multiple reports. */
+ @Parameter(
+ names = ["--split-after", "-s"], required = false, arity = 1, description = "After how many tests " +
+ "testwise coverage should be split into multiple reports (Default is 5000)."
+ )
+ val splitAfter = 5000
+
+ @Throws(IOException::class)
+ fun getClassDirectoriesOrZips(): List = ClasspathUtils
+ .resolveClasspathTextFiles(
+ "class-dir", FilePatternResolver(CommandLineLogger()),
+ classDirectoriesOrZips
+ )
+
+ fun getInputFiles() = inputFiles.map { File(it) }
+ fun getOutputFile() = File(outputFile)
+
+ /** Makes sure the arguments are valid. */
+ override fun validate() = Validator().apply {
+ val classDirectoriesOrZips = mutableListOf()
+ ensure { classDirectoriesOrZips.addAll(getClassDirectoriesOrZips()) }
+ isFalse(
+ classDirectoriesOrZips.isEmpty(),
+ "You must specify at least one directory or zip that contains class files"
+ )
+ classDirectoriesOrZips.forEach { path ->
+ isTrue(path.exists(), "Path '$path' does not exist")
+ isTrue(path.canRead(), "Path '$path' is not readable")
+ }
+ getInputFiles().forEach { inputFile ->
+ isTrue(inputFile.exists() && inputFile.canRead(), "Cannot read the input file $inputFile")
+ }
+ ensure {
+ Assertions.isFalse(isEmpty(outputFile), "You must specify an output file")
+ val outputDir = getOutputFile().getAbsoluteFile().getParentFile()
+ ensureDirectoryExists(outputDir)
+ Assertions.isTrue(outputDir.canWrite(), "Path '$outputDir' is not writable")
+ }
+ }
+
+ /** {@inheritDoc} */
+ @Throws(Exception::class)
+ override fun run() {
+ Converter(this).apply {
+ if (shouldGenerateTestwiseCoverage) {
+ runTestwiseCoverageReportGeneration()
+ } else {
+ runJaCoCoReportGeneration()
+ }
+ }
+ }
+}
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/convert/Converter.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/convert/Converter.kt
new file mode 100644
index 000000000..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..f646f9848
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/LogToTeamscaleAppender.kt
@@ -0,0 +1,183 @@
+package com.teamscale.jacoco.agent.logging
+
+import ch.qos.logback.classic.Logger
+import ch.qos.logback.classic.LoggerContext
+import ch.qos.logback.classic.spi.ILoggingEvent
+import ch.qos.logback.core.AppenderBase
+import ch.qos.logback.core.status.ErrorStatus
+import com.teamscale.client.ITeamscaleService
+import com.teamscale.client.ProfilerLogEntry
+import com.teamscale.jacoco.agent.options.AgentOptions
+import java.net.ConnectException
+import java.time.Duration
+import java.util.Collections
+import java.util.IdentityHashMap
+import java.util.LinkedHashSet
+import java.util.concurrent.CompletableFuture
+import java.util.concurrent.Executors
+import java.util.concurrent.ScheduledExecutorService
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.function.BiConsumer
+
+/**
+ * Custom log appender that sends logs to Teamscale; it buffers log that were not sent due to connection issues and
+ * sends them later.
+ */
+class LogToTeamscaleAppender : AppenderBase() {
+ /** The unique ID of the profiler */
+ private var profilerId: String? = null
+
+ /**
+ * Buffer for unsent logs. We use a set here to allow for removing entries fast after sending them to Teamscale was
+ * successful.
+ */
+ private val logBuffer = LinkedHashSet()
+
+ /** Scheduler for sending logs after the configured time interval */
+ private val scheduler: ScheduledExecutorService = Executors.newScheduledThreadPool(1) { r ->
+ // Make the thread a daemon so that it does not prevent the JVM from terminating.
+ val t = Executors.defaultThreadFactory().newThread(r)
+ t.setDaemon(true)
+ t
+ }
+
+ /** Active log flushing threads */
+ private val activeLogFlushes: MutableSet> =
+ Collections.newSetFromMap(IdentityHashMap())
+
+ /** Is there a flush going on right now? */
+ private val isFlusing = AtomicBoolean(false)
+
+ override fun start() {
+ super.start()
+ scheduler.scheduleAtFixedRate({
+ synchronized(activeLogFlushes) {
+ activeLogFlushes.removeIf { it.isDone }
+ if (activeLogFlushes.isEmpty()) flush()
+ }
+ }, FLUSH_INTERVAL.toMillis(), FLUSH_INTERVAL.toMillis(), TimeUnit.MILLISECONDS)
+ }
+
+ override fun append(eventObject: ILoggingEvent) {
+ synchronized(logBuffer) {
+ logBuffer.add(formatLog(eventObject))
+ if (logBuffer.size >= BATCH_SIZE) flush()
+ }
+ }
+
+ private fun formatLog(eventObject: ILoggingEvent): ProfilerLogEntry {
+ val trace = LoggingUtils.getStackTraceFromEvent(eventObject)
+ val timestamp = eventObject.timeStamp
+ val message = eventObject.formattedMessage
+ val severity = eventObject.level.toString()
+ return ProfilerLogEntry(timestamp, message, trace, severity)
+ }
+
+ private fun flush() {
+ sendLogs()
+ }
+
+ /** Send logs in a separate thread */
+ private fun sendLogs() {
+ synchronized(activeLogFlushes) {
+ activeLogFlushes.add(CompletableFuture.runAsync {
+ if (isFlusing.compareAndSet(false, true)) {
+ try {
+ val client = teamscaleClient ?: return@runAsync // There might be no connection configured.
+
+ val logsToSend: MutableList
+ synchronized(logBuffer) {
+ logsToSend = logBuffer.toMutableList()
+ }
+
+ val call = client.postProfilerLog(profilerId!!, logsToSend)
+ val response = call.execute()
+ check(response.isSuccessful) { "Failed to send log: HTTP error code : ${response.code()}" }
+
+ synchronized(logBuffer) {
+ // Removing the logs that have been sent after the fact.
+ // This handles problems with lost network connections.
+ logBuffer.removeAll(logsToSend.toSet())
+ }
+ } catch (e: Exception) {
+ // We do not report on exceptions here.
+ if (e !is ConnectException) {
+ addStatus(ErrorStatus("Sending logs to Teamscale failed: ${e.message}", this, e))
+ }
+ } finally {
+ isFlusing.set(false)
+ }
+ }
+ }.whenComplete(BiConsumer { _, _ ->
+ synchronized(activeLogFlushes) {
+ activeLogFlushes.removeIf { it.isDone }
+ }
+ }))
+ }
+ }
+
+ override fun stop() {
+ // Already flush here once to make sure that we do not miss too much.
+ flush()
+
+ scheduler.shutdown()
+ try {
+ if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) {
+ scheduler.shutdownNow()
+ }
+ } catch (_: InterruptedException) {
+ scheduler.shutdownNow()
+ }
+
+ // A final flush after the scheduler has been shut down.
+ flush()
+
+ // Block until all flushes are done
+ CompletableFuture.allOf(*activeLogFlushes.toTypedArray()).join()
+
+ super.stop()
+ }
+
+ fun setTeamscaleClient(teamscaleClient: ITeamscaleService?) {
+ Companion.teamscaleClient = teamscaleClient
+ }
+
+ fun setProfilerId(profilerId: String) {
+ this.profilerId = profilerId
+ }
+
+ companion object {
+ /** Flush the logs after N elements are in the queue */
+ private const val BATCH_SIZE = 50
+
+ /** Flush the logs in the given time interval */
+ private val FLUSH_INTERVAL: Duration = Duration.ofSeconds(3)
+
+ /** The service client for sending logs to Teamscale */
+ private var teamscaleClient: ITeamscaleService? = null
+
+ /**
+ * Add the [LogToTeamscaleAppender] to the logging configuration and
+ * enable/start it.
+ */
+ fun addTeamscaleAppenderTo(context: LoggerContext, agentOptions: AgentOptions): Boolean {
+ val client = agentOptions.createTeamscaleClient(false)
+ if (client == null || agentOptions.configurationViaTeamscale == null) {
+ return false
+ }
+
+ context.getLogger(Logger.ROOT_LOGGER_NAME).apply {
+ val logToTeamscaleAppender = LogToTeamscaleAppender().apply {
+ setContext(context)
+ setProfilerId(agentOptions.configurationViaTeamscale.profilerId!!)
+ setTeamscaleClient(client.service)
+ start()
+ }
+ addAppender(logToTeamscaleAppender)
+ }
+
+ return true
+ }
+ }
+}
\ No newline at end of file
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/LoggingUtils.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/LoggingUtils.kt
new file mode 100644
index 000000000..0e0a244a9
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/LoggingUtils.kt
@@ -0,0 +1,124 @@
+package com.teamscale.jacoco.agent.logging
+
+import ch.qos.logback.classic.LoggerContext
+import ch.qos.logback.classic.joran.JoranConfigurator
+import ch.qos.logback.classic.spi.ILoggingEvent
+import ch.qos.logback.classic.spi.ThrowableProxy
+import ch.qos.logback.classic.spi.ThrowableProxyUtil
+import ch.qos.logback.core.joran.spi.JoranException
+import ch.qos.logback.core.util.StatusPrinter
+import com.teamscale.jacoco.agent.Agent
+import com.teamscale.jacoco.agent.util.NullOutputStream
+import com.teamscale.report.util.ILogger
+import org.slf4j.Logger
+import org.slf4j.LoggerFactory
+import java.io.FileInputStream
+import java.io.IOException
+import java.io.InputStream
+import java.io.PrintStream
+import java.lang.AutoCloseable
+import java.nio.file.Path
+
+/**
+ * Helps initialize the logging framework properly.
+ */
+object LoggingUtils {
+ /** Returns a logger for the given object's class. */
+ @JvmStatic
+ fun getLogger(obj: Any): Logger = LoggerFactory.getLogger(obj.javaClass)
+
+ /** Returns a logger for the given class. */
+ @JvmStatic
+ fun getLogger(obj: Class<*>): Logger = LoggerFactory.getLogger(obj)
+
+ /** Initializes the logging to the default configured in the Jar. */
+ fun initializeDefaultLogging(): LoggingResources {
+ val stream = Agent::class.java.getResourceAsStream("logback-default.xml")
+ reconfigureLoggerContext(stream)
+ return LoggingResources()
+ }
+
+ /**
+ * Returns the logger context.
+ */
+ val loggerContext: LoggerContext
+ get() = LoggerFactory.getILoggerFactory() as LoggerContext
+
+ /**
+ * Extracts the stack trace from an ILoggingEvent using ThrowableProxyUtil.
+ *
+ * @param event the logging event containing the exception
+ * @return the stack trace as a String, or null if no exception is associated
+ */
+ fun getStackTraceFromEvent(event: ILoggingEvent) =
+ event.throwableProxy?.let { ThrowableProxyUtil.asString(it) }
+
+ /**
+ * Converts a Throwable to its stack trace as a String.
+ *
+ * @param throwable the throwable to convert
+ * @return the stack trace as a String
+ */
+ @JvmStatic
+ fun getStackTraceAsString(throwable: Throwable?) =
+ throwable?.let { ThrowableProxyUtil.asString(ThrowableProxy(it)) }
+
+ /**
+ * Reconfigures the logger context to use the configuration XML from the given input stream. Cf. [https://logback.qos.ch/manual/configuration.html](https://logback.qos.ch/manual/configuration.html)
+ */
+ private fun reconfigureLoggerContext(stream: InputStream?) {
+ StatusPrinter.setPrintStream(PrintStream(NullOutputStream()))
+ try {
+ val configurator = JoranConfigurator()
+ configurator.setContext(loggerContext)
+ loggerContext.reset()
+ configurator.doConfigure(stream)
+ } catch (_: JoranException) {
+ // StatusPrinter will handle this
+ }
+ StatusPrinter.printInCaseOfErrorsOrWarnings(loggerContext)
+ }
+
+ /**
+ * Initializes the logging from the given file. If that is `null`, uses [ ][.initializeDefaultLogging] instead.
+ */
+ @Throws(IOException::class)
+ fun initializeLogging(loggingConfigFile: Path?): LoggingResources {
+ if (loggingConfigFile == null) {
+ return initializeDefaultLogging()
+ }
+
+ reconfigureLoggerContext(FileInputStream(loggingConfigFile.toFile()))
+ return LoggingResources()
+ }
+
+ /** Initializes debug logging. */
+ fun initializeDebugLogging(logDirectory: Path?): LoggingResources {
+ if (logDirectory != null) {
+ DebugLogDirectoryPropertyDefiner.filePath = logDirectory
+ }
+ val stream = Agent::class.java.getResourceAsStream("logback-default-debugging.xml")
+ reconfigureLoggerContext(stream)
+ return LoggingResources()
+ }
+
+ /** Wraps the given slf4j logger into an [com.teamscale.report.util.ILogger]. */
+ @JvmStatic
+ fun wrap(logger: Logger): ILogger {
+ return object : ILogger {
+ override fun debug(message: String) = logger.debug(message)
+ override fun info(message: String) = logger.info(message)
+ override fun warn(message: String) = logger.warn(message)
+ override fun warn(message: String, throwable: Throwable?) = logger.warn(message, throwable)
+ override fun error(throwable: Throwable) = logger.error(throwable.message, throwable)
+ override fun error(message: String, throwable: Throwable?) = logger.error(message, throwable)
+ }
+ }
+
+ /** Class to use with try-with-resources to close the logging framework's resources. */
+ class LoggingResources : AutoCloseable {
+ override fun close() {
+ loggerContext.stop()
+ }
+ }
+}
\ No newline at end of file
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/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/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..fe66374f1
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/JacocoAgentOptionsBuilder.kt
@@ -0,0 +1,89 @@
+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)
+ }
+ 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
+ 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.getOutputDirectory(), "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/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..662e03445
--- /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: MutableList,
+ /** The API class. */
+ private val apiClass: Class
+) : IUploader {
+ /** The logger. */
+ @JvmField
+ protected val logger: Logger = getLogger(this)
+
+ /** The API which performs the upload */
+ protected val api: T by lazy {
+ val retrofit = createRetrofit(
+ { baseUrl(uploadUrl) },
+ { configureOkHttp(this) }
+ )
+ retrofit.create(apiClass)
+ }
+
+ /** Template method to configure the OkHttp Client. */
+ protected open fun configureOkHttp(builder: OkHttpClient.Builder) {
+ }
+
+ /** Uploads the coverage zip to the server */
+ @Throws(IOException::class, UploaderException::class)
+ protected abstract fun uploadCoverageZip(coverageFile: File): Response
+
+ override fun upload(coverageFile: CoverageFile) {
+ try {
+ benchmark("Uploading report via HTTP") {
+ if (tryUpload(coverageFile)) {
+ coverageFile.delete()
+ } else {
+ logger.warn(
+ ("Failed to upload coverage to Teamscale. "
+ + "Won't delete local file {} so that the upload can automatically be retried upon profiler restart. "
+ + "Upload can also be retried manually."), coverageFile
+ )
+ (this as? IUploadRetry)?.markFileForUploadRetry(coverageFile)
+ }
+ }
+ } catch (_: IOException) {
+ logger.warn("Could not delete file {} after upload", coverageFile)
+ }
+ }
+
+ /** Performs the upload and returns `true` if successful. */
+ protected fun tryUpload(coverageFile: CoverageFile): Boolean {
+ logger.debug("Uploading coverage to {}", uploadUrl)
+
+ val zipFile: File
+ try {
+ zipFile = createZipFile(coverageFile)
+ } catch (e: IOException) {
+ logger.error("Failed to compile coverage zip file for upload to {}", uploadUrl, e)
+ return false
+ }
+
+ try {
+ val response = uploadCoverageZip(zipFile)
+ if (response.isSuccessful) {
+ return true
+ }
+
+ var errorBody = ""
+ if (response.errorBody() != null) {
+ errorBody = response.errorBody()!!.string()
+ }
+
+ logger.error(
+ "Failed to upload coverage to {}. Request failed with error code {}. Error:\n{}", uploadUrl,
+ response.code(), errorBody
+ )
+ return false
+ } catch (e: IOException) {
+ logger.error("Failed to upload coverage to {}. Probably a network problem", uploadUrl, e)
+ return false
+ } catch (e: UploaderException) {
+ logger.error("Failed to upload coverage to {}. The configuration is probably incorrect", uploadUrl, e)
+ return false
+ } finally {
+ zipFile.delete()
+ }
+ }
+
+ /**
+ * Creates the zip file in the system temp directory to upload which includes the given coverage XML and all
+ * [.additionalMetaDataFiles]. The file is marked to be deleted on exit.
+ */
+ @Throws(IOException::class)
+ private fun createZipFile(coverageFile: CoverageFile): File {
+ val zipFile = Files.createTempFile(coverageFile.nameWithoutExtension, ".zip").toFile()
+ zipFile.deleteOnExit()
+ FileOutputStream(zipFile).use { fileOutputStream ->
+ ZipOutputStream(fileOutputStream).use { zipOutputStream ->
+ fillZipFile(zipOutputStream, coverageFile)
+ return zipFile
+ }
+ }
+ }
+
+ /**
+ * Fills the upload zip file with the given coverage XML and all [.additionalMetaDataFiles].
+ */
+ @Throws(IOException::class)
+ private fun fillZipFile(zipOutputStream: ZipOutputStream, coverageFile: CoverageFile) {
+ zipOutputStream.putNextEntry(ZipEntry(getZipEntryCoverageFileName(coverageFile)))
+ coverageFile.copyStream(zipOutputStream)
+
+ for (additionalFile in additionalMetaDataFiles) {
+ zipOutputStream.putNextEntry(ZipEntry(additionalFile.fileName.toString()))
+ zipOutputStream.write(readFileBinary(additionalFile.toFile()))
+ }
+ }
+
+ protected open fun getZipEntryCoverageFileName(coverageFile: CoverageFile): String {
+ return "coverage.xml"
+ }
+}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/upload/IUploadRetry.java b/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/IUploadRetry.kt
similarity index 53%
rename from agent/src/main/java/com/teamscale/jacoco/agent/upload/IUploadRetry.java
rename to agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/IUploadRetry.kt
index eaf9fa9ed..d3ff3393f 100644
--- a/agent/src/main/java/com/teamscale/jacoco/agent/upload/IUploadRetry.java
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/IUploadRetry.kt
@@ -1,23 +1,21 @@
-package com.teamscale.jacoco.agent.upload;
+package com.teamscale.jacoco.agent.upload
-import java.util.Properties;
-
-import com.teamscale.report.jacoco.CoverageFile;
+import com.teamscale.report.jacoco.CoverageFile
+import java.util.*
/**
* Interface for all the uploaders that support an automatic upload retry
* mechanism.
*/
-public interface IUploadRetry {
-
+interface IUploadRetry {
/**
* Marks coverage files of unsuccessful coverage uploads so that they can be
* reuploaded at next agent start.
*/
- void markFileForUploadRetry(CoverageFile coverageFile);
+ fun markFileForUploadRetry(coverageFile: CoverageFile)
/**
* Retries previously unsuccessful coverage uploads with the given properties.
*/
- void reupload(CoverageFile coverageFile, Properties properties);
+ fun reupload(coverageFile: CoverageFile, properties: Properties)
}
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/IUploader.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/IUploader.kt
new file mode 100644
index 000000000..bc85d0004
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/IUploader.kt
@@ -0,0 +1,16 @@
+package com.teamscale.jacoco.agent.upload
+
+import com.teamscale.report.jacoco.CoverageFile
+
+/** Uploads coverage reports. */
+interface IUploader {
+ /**
+ * Uploads the given coverage file. If the upload was successful, the coverage
+ * file on disk will be deleted. Otherwise the file is left on disk and a
+ * warning is logged.
+ */
+ fun upload(coverageFile: CoverageFile)
+
+ /** Human-readable description of the uploader. */
+ fun describe(): String
+}
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/LocalDiskUploader.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/LocalDiskUploader.kt
new file mode 100644
index 000000000..c4ebe0681
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/LocalDiskUploader.kt
@@ -0,0 +1,16 @@
+package com.teamscale.jacoco.agent.upload
+
+import com.teamscale.report.jacoco.CoverageFile
+
+/**
+ * Dummy uploader which keeps the coverage file written by the agent on disk,
+ * but does not actually perform uploads.
+ */
+class LocalDiskUploader : IUploader {
+ override fun upload(coverageFile: CoverageFile) {
+ // Don't delete the file here. We want to store the file permanently on disk in
+ // case no uploader is configured.
+ }
+
+ override fun describe() = "configured output directory on the local disk"
+}
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/UploaderException.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/UploaderException.kt
new file mode 100644
index 000000000..bd50cca8f
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/UploaderException.kt
@@ -0,0 +1,32 @@
+package com.teamscale.jacoco.agent.upload
+
+import okhttp3.ResponseBody
+import retrofit2.Response
+import java.io.IOException
+
+/**
+ * Exception thrown from an uploader. Either during the upload or in the validation process.
+ */
+class UploaderException : Exception {
+ /** Constructor */
+ constructor(message: String, e: Exception) : super(message, e)
+
+ /** Constructor */
+ constructor(message: String) : super(message)
+
+ /** Constructor */
+ constructor(message: String, response: Response) : super(createResponseMessage(message, response))
+
+ companion object {
+ private fun createResponseMessage(message: String, response: Response): String {
+ try {
+ val errorBodyMessage = response.errorBody()!!.string()
+ return "$message (${response.code()}): \n$errorBodyMessage"
+ } catch (_: IOException) {
+ return message
+ } catch (_: NullPointerException) {
+ return message
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/artifactory/ArtifactoryConfig.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/artifactory/ArtifactoryConfig.kt
new file mode 100644
index 000000000..9d42f4e6c
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/artifactory/ArtifactoryConfig.kt
@@ -0,0 +1,206 @@
+package com.teamscale.jacoco.agent.upload.artifactory
+
+import com.teamscale.client.StringUtils.stripSuffix
+import com.teamscale.jacoco.agent.commit_resolution.git_properties.CommitInfo
+import com.teamscale.jacoco.agent.commit_resolution.git_properties.GitPropertiesLocatorUtils
+import com.teamscale.jacoco.agent.commit_resolution.git_properties.InvalidGitPropertiesException
+import com.teamscale.jacoco.agent.options.AgentOptionParseException
+import com.teamscale.jacoco.agent.options.AgentOptionsParser
+import com.teamscale.jacoco.agent.upload.UploaderException
+import com.teamscale.jacoco.agent.upload.artifactory.ArtifactoryConfig.Companion.ARTIFACTORY_API_KEY_OPTION
+import com.teamscale.jacoco.agent.upload.artifactory.ArtifactoryConfig.Companion.ARTIFACTORY_LEGACY_PATH_OPTION
+import com.teamscale.jacoco.agent.upload.artifactory.ArtifactoryConfig.Companion.ARTIFACTORY_PARTITION
+import com.teamscale.jacoco.agent.upload.artifactory.ArtifactoryConfig.Companion.ARTIFACTORY_PASSWORD_OPTION
+import com.teamscale.jacoco.agent.upload.artifactory.ArtifactoryConfig.Companion.ARTIFACTORY_PATH_SUFFIX
+import com.teamscale.jacoco.agent.upload.artifactory.ArtifactoryConfig.Companion.ARTIFACTORY_USER_OPTION
+import com.teamscale.jacoco.agent.upload.artifactory.ArtifactoryConfig.Companion.ARTIFACTORY_ZIP_PATH_OPTION
+import okhttp3.HttpUrl
+import java.io.File
+import java.io.IOException
+import java.time.format.DateTimeFormatter
+
+/** Config necessary to upload files to an azure file storage. */
+class ArtifactoryConfig {
+ /** Related to [ARTIFACTORY_USER_OPTION] */
+ @JvmField
+ var url: HttpUrl? = null
+
+ /** Related to [ARTIFACTORY_USER_OPTION] */
+ @JvmField
+ var user: String? = null
+
+ /** Related to [ARTIFACTORY_PASSWORD_OPTION] */
+ @JvmField
+ var password: String? = null
+
+ /** Related to [ARTIFACTORY_LEGACY_PATH_OPTION] */
+ var legacyPath: Boolean = false
+
+ /** Related to [ARTIFACTORY_ZIP_PATH_OPTION] */
+ var zipPath: String? = null
+
+ /** Related to [ARTIFACTORY_PATH_SUFFIX] */
+ var pathSuffix: String? = null
+
+ /** The information regarding a commit. */
+ @JvmField
+ var commitInfo: CommitInfo? = null
+
+ /** Related to [ARTIFACTORY_API_KEY_OPTION] */
+ @JvmField
+ var apiKey: String? = null
+
+ /** Related to [ARTIFACTORY_PARTITION] */
+ @JvmField
+ var partition: String? = null
+
+ /** Checks if all required options are set to upload to artifactory. */
+ fun hasAllRequiredFieldsSet(): Boolean {
+ val requiredAuthOptionsSet = (user != null && password != null) || apiKey != null
+ val partitionSet = partition != null || legacyPath
+ return url != null && partitionSet && requiredAuthOptionsSet
+ }
+
+ /** Checks if all required fields are null. */
+ fun hasAllRequiredFieldsNull() = url == null && user == null && password == null && apiKey == null && partition == null
+
+ /** Checks whether commit and revision are set. */
+ fun hasCommitInfo() = commitInfo != null
+
+ companion object {
+ /**
+ * Option to specify the artifactory URL. This shall be the entire path down to the directory to which the coverage
+ * should be uploaded to, not only the base url of artifactory.
+ */
+ const val ARTIFACTORY_URL_OPTION: String = "artifactory-url"
+
+ /**
+ * Username that shall be used for basic auth. Alternative to basic auth is to use an API key with the
+ * [ARTIFACTORY_API_KEY_OPTION]
+ */
+ const val ARTIFACTORY_USER_OPTION: String = "artifactory-user"
+
+ /**
+ * Password that shall be used for basic auth. Alternative to basic auth is to use an API key with the
+ * [ARTIFACTORY_API_KEY_OPTION]
+ */
+ const val ARTIFACTORY_PASSWORD_OPTION: String = "artifactory-password"
+
+ /**
+ * API key that shall be used to authenticate requests to artifactory with the
+ * [ArtifactoryUploader.ARTIFACTORY_API_HEADER]. Alternatively
+ * basic auth with username ([ARTIFACTORY_USER_OPTION]) and password
+ * ([ARTIFACTORY_PASSWORD_OPTION]) can be used.
+ */
+ const val ARTIFACTORY_API_KEY_OPTION: String = "artifactory-api-key"
+
+ /**
+ * Option that specifies if the legacy path for uploading files to artifactory should be used instead of the new
+ * standard path.
+ */
+ const val ARTIFACTORY_LEGACY_PATH_OPTION: String = "artifactory-legacy-path"
+
+ /**
+ * Option that specifies under which path the coverage file shall lie within the zip file that is created for the
+ * upload.
+ */
+ const val ARTIFACTORY_ZIP_PATH_OPTION: String = "artifactory-zip-path"
+
+ /**
+ * Option that specifies intermediate directories which should be appended.
+ */
+ const val ARTIFACTORY_PATH_SUFFIX: String = "artifactory-path-suffix"
+
+ /**
+ * Specifies the location of the JAR file which includes the git.properties file.
+ */
+ const val ARTIFACTORY_GIT_PROPERTIES_JAR_OPTION: String = "artifactory-git-properties-jar"
+
+ /**
+ * Specifies the date format in which the commit timestamp in the git.properties file is formatted.
+ */
+ const val ARTIFACTORY_GIT_PROPERTIES_COMMIT_DATE_FORMAT_OPTION: String =
+ "artifactory-git-properties-commit-date-format"
+
+ /**
+ * Specifies the partition for which the upload is.
+ */
+ const val ARTIFACTORY_PARTITION: String = "artifactory-partition"
+
+ /**
+ * Handles all command-line options prefixed with 'artifactory-'
+ *
+ * @return true if it has successfully processed the given option.
+ */
+ @JvmStatic
+ @Throws(AgentOptionParseException::class)
+ fun handleArtifactoryOptions(options: ArtifactoryConfig, key: String, value: String): Boolean {
+ when (key) {
+ ARTIFACTORY_URL_OPTION -> {
+ options.url = AgentOptionsParser.parseUrl(key, value)
+ return true
+ }
+ ARTIFACTORY_USER_OPTION -> {
+ options.user = value
+ return true
+ }
+ ARTIFACTORY_PASSWORD_OPTION -> {
+ options.password = value
+ return true
+ }
+ ARTIFACTORY_LEGACY_PATH_OPTION -> {
+ options.legacyPath = value.toBoolean()
+ return true
+ }
+ ARTIFACTORY_ZIP_PATH_OPTION -> {
+ options.zipPath = stripSuffix(value, "/")
+ return true
+ }
+ ARTIFACTORY_PATH_SUFFIX -> {
+ options.pathSuffix = stripSuffix(value, "/")
+ return true
+ }
+ ARTIFACTORY_API_KEY_OPTION -> {
+ options.apiKey = value
+ return true
+ }
+ ARTIFACTORY_PARTITION -> {
+ options.partition = value
+ return true
+ }
+ else -> return false
+ }
+ }
+
+ /** Parses the commit information form a git.properties file. */
+ @JvmStatic
+ @Throws(UploaderException::class)
+ fun parseGitProperties(
+ jarFile: File, searchRecursively: Boolean, gitPropertiesCommitTimeFormat: DateTimeFormatter?
+ ): CommitInfo? {
+ try {
+ val commitInfo = GitPropertiesLocatorUtils.getCommitInfoFromGitProperties(
+ jarFile,
+ true,
+ searchRecursively,
+ gitPropertiesCommitTimeFormat
+ )
+ if (commitInfo.isEmpty()) {
+ throw UploaderException("Found no git.properties files in $jarFile")
+ }
+ if (commitInfo.size > 1) {
+ throw UploaderException(
+ ("Found multiple git.properties files in " + jarFile
+ + ". Uploading to multiple projects is currently not possible with Artifactory. "
+ + "Please contact CQSE if you need this feature.")
+ )
+ }
+ return commitInfo.firstOrNull()
+ } catch (e: IOException) {
+ throw UploaderException("Could not locate a valid git.properties file in $jarFile", e)
+ } catch (e: InvalidGitPropertiesException) {
+ throw UploaderException("Could not locate a valid git.properties file in $jarFile", e)
+ }
+ }
+ }
+}
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/artifactory/ArtifactoryUploader.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/artifactory/ArtifactoryUploader.kt
new file mode 100644
index 000000000..885ba65d4
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/artifactory/ArtifactoryUploader.kt
@@ -0,0 +1,145 @@
+package com.teamscale.jacoco.agent.upload.artifactory
+
+import com.teamscale.client.CommitDescriptor.Companion.parse
+import com.teamscale.client.EReportFormat
+import com.teamscale.client.FileSystemUtils.normalizeSeparators
+import com.teamscale.client.FileSystemUtils.replaceFilePathFilenameWith
+import com.teamscale.client.HttpUtils.getBasicAuthInterceptor
+import com.teamscale.client.StringUtils.emptyToNull
+import com.teamscale.client.StringUtils.nullToEmpty
+import com.teamscale.jacoco.agent.commit_resolution.git_properties.CommitInfo
+import com.teamscale.jacoco.agent.upload.HttpZipUploaderBase
+import com.teamscale.jacoco.agent.upload.IUploadRetry
+import com.teamscale.jacoco.agent.upload.teamscale.ETeamscaleServerProperties
+import com.teamscale.jacoco.agent.upload.teamscale.TeamscaleUploader
+import com.teamscale.report.jacoco.CoverageFile
+import okhttp3.Interceptor
+import okhttp3.OkHttpClient
+import okhttp3.ResponseBody
+import retrofit2.Response
+import java.io.File
+import java.io.FileWriter
+import java.io.IOException
+import java.nio.file.Path
+import java.util.*
+import kotlin.Throws
+
+/**
+ * Uploads XMLs to Artifactory.
+ */
+class ArtifactoryUploader(
+ private val artifactoryConfig: ArtifactoryConfig,
+ additionalMetaDataFiles: MutableList,
+ reportFormat: EReportFormat
+) : HttpZipUploaderBase(
+ artifactoryConfig.url!!,
+ additionalMetaDataFiles,
+ IArtifactoryUploadApi::class.java
+), IUploadRetry {
+ private val coverageFormat = reportFormat.name.lowercase(Locale.getDefault())
+ private var uploadPath: String? = null
+
+ override fun markFileForUploadRetry(coverageFile: CoverageFile) {
+ val uploadMetadataFile = File(
+ replaceFilePathFilenameWith(
+ normalizeSeparators(coverageFile.toString()),
+ "${coverageFile.name}${TeamscaleUploader.RETRY_UPLOAD_FILE_SUFFIX}"
+ )
+ )
+ val properties = createArtifactoryProperties()
+ try {
+ FileWriter(uploadMetadataFile).use { writer ->
+ properties.store(writer, null)
+ }
+ } catch (_: IOException) {
+ logger.warn(
+ "Failed to create metadata file for automatic upload retry of {}. Please manually retry the coverage upload to Azure.",
+ coverageFile
+ )
+ uploadMetadataFile.delete()
+ }
+ }
+
+ override fun reupload(coverageFile: CoverageFile, properties: Properties) {
+ val config = ArtifactoryConfig()
+ config.url = artifactoryConfig.url
+ config.user = artifactoryConfig.user
+ config.password = artifactoryConfig.password
+ config.legacyPath = artifactoryConfig.legacyPath
+ config.zipPath = artifactoryConfig.zipPath
+ config.pathSuffix = artifactoryConfig.pathSuffix
+ val revision = properties.getProperty(ETeamscaleServerProperties.REVISION.name)
+ val commitString = properties.getProperty(ETeamscaleServerProperties.COMMIT.name)
+ config.commitInfo = CommitInfo(revision, parse(commitString))
+ config.apiKey = artifactoryConfig.apiKey
+ config.partition = emptyToNull(properties.getProperty(ETeamscaleServerProperties.PARTITION.name))
+ setUploadPath(coverageFile, config)
+ super.upload(coverageFile)
+ }
+
+ /** Creates properties from the artifactory configs. */
+ private fun createArtifactoryProperties() = Properties().apply {
+ setProperty(ETeamscaleServerProperties.REVISION.name, artifactoryConfig.commitInfo!!.revision)
+ setProperty(ETeamscaleServerProperties.COMMIT.name, artifactoryConfig.commitInfo!!.commit.toString())
+ setProperty(ETeamscaleServerProperties.PARTITION.name, nullToEmpty(artifactoryConfig.partition))
+ }
+
+ override fun configureOkHttp(builder: OkHttpClient.Builder) {
+ super.configureOkHttp(builder)
+ if (artifactoryConfig.apiKey != null) {
+ builder.addInterceptor(this.artifactoryApiHeaderInterceptor)
+ } else {
+ builder.addInterceptor(
+ getBasicAuthInterceptor(artifactoryConfig.user!!, artifactoryConfig.password!!)
+ )
+ }
+ }
+
+ private fun setUploadPath(coverageFile: CoverageFile, artifactoryConfig: ArtifactoryConfig) {
+ val commit = artifactoryConfig.commitInfo?.commit ?: return
+ val revision = artifactoryConfig.commitInfo?.revision ?: return
+ val timeRev = "${commit.timestamp}-${revision}"
+ val fileName = "${coverageFile.nameWithoutExtension}.zip"
+
+ uploadPath = if (artifactoryConfig.legacyPath) {
+ "${commit.branchName}/$timeRev/$fileName"
+ } else {
+ val suffixSegment = artifactoryConfig.pathSuffix?.let { "$it/" } ?: ""
+ "uploads/${commit.branchName}/$timeRev/${artifactoryConfig.partition}/$coverageFormat/$suffixSegment$fileName"
+ }
+ }
+
+ override fun upload(coverageFile: CoverageFile) {
+ setUploadPath(coverageFile, this.artifactoryConfig)
+ super.upload(coverageFile)
+ }
+
+ @Throws(IOException::class)
+ override fun uploadCoverageZip(coverageFile: File): Response =
+ api.uploadCoverageZip(uploadPath!!, coverageFile)
+
+ override fun getZipEntryCoverageFileName(coverageFile: CoverageFile): String {
+ var path = coverageFile.name
+ artifactoryConfig.zipPath?.let { path = "$it/$path" }
+ return path
+ }
+
+ /** {@inheritDoc} */
+ override fun describe() = "Uploading to $uploadUrl"
+
+ private val artifactoryApiHeaderInterceptor: Interceptor
+ get() = Interceptor { chain ->
+ val newRequest = chain.request().newBuilder()
+ .header(ARTIFACTORY_API_HEADER, artifactoryConfig.apiKey!!)
+ .build()
+ chain.proceed(newRequest)
+ }
+
+ companion object {
+ /**
+ * Header that can be used as alternative to basic authentication to authenticate requests against artifactory. For
+ * details check https://www.jfrog.com/confluence/display/JFROG/Artifactory+REST+API
+ */
+ const val ARTIFACTORY_API_HEADER: String = "X-JFrog-Art-Api"
+ }
+}
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/artifactory/IArtifactoryUploadApi.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/artifactory/IArtifactoryUploadApi.kt
new file mode 100644
index 000000000..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..05a77007f
--- /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: MutableList
+) : 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