stream = Files.list(directory)) {
- if (stream.findFirst().isPresent()) {
- return;
- }
- }
-
- Files.delete(directory);
- }
-
- /**
- * Dumps the current execution data, converts it, writes it to the output directory defined in {@link #options} and
- * uploads it if an uploader is configured. Logs any errors, never throws an exception.
- */
- @Override
- public void dumpReport() {
- logger.debug("Starting dump");
-
- try {
- dumpReportUnsafe();
- } catch (Throwable t) {
- // we want to catch anything in order to avoid crashing the whole system under
- // test
- logger.error("Dump job failed with an exception", t);
- }
- }
-
- private void dumpReportUnsafe() {
- Dump dump;
- try {
- dump = controller.dumpAndReset();
- } catch (JacocoRuntimeController.DumpException e) {
- logger.error("Dumping failed, retrying later", e);
- return;
- }
-
- try (Benchmark ignored = new Benchmark("Generating the XML report")) {
- File outputFile = options.createNewFileInOutputDirectory("jacoco", "xml");
- CoverageFile coverageFile = generator.convertSingleDumpToReport(dump, outputFile);
- uploader.upload(coverageFile);
- } catch (IOException e) {
- logger.error("Converting binary dump to XML failed", e);
- } catch (EmptyReportException e) {
- logger.error("No coverage was collected. " + e.getMessage(), e);
- }
- }
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/AgentBase.java b/agent/src/main/java/com/teamscale/jacoco/agent/AgentBase.java
deleted file mode 100644
index 13b98f37e..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/AgentBase.java
+++ /dev/null
@@ -1,157 +0,0 @@
-package com.teamscale.jacoco.agent;
-
-import com.teamscale.jacoco.agent.logging.LoggingUtils;
-import com.teamscale.jacoco.agent.options.AgentOptions;
-import org.eclipse.jetty.server.Server;
-import org.eclipse.jetty.server.ServerConnector;
-import org.eclipse.jetty.servlet.ServletContextHandler;
-import org.eclipse.jetty.servlet.ServletHolder;
-import org.eclipse.jetty.util.thread.QueuedThreadPool;
-import org.glassfish.jersey.server.ResourceConfig;
-import org.glassfish.jersey.servlet.ServletContainer;
-import org.jacoco.agent.rt.RT;
-import org.slf4j.Logger;
-
-import java.lang.management.ManagementFactory;
-
-/**
- * Base class for agent implementations. Handles logger shutdown, store creation and instantiation of the
- * {@link JacocoRuntimeController}.
- *
- * Subclasses must handle dumping onto disk and uploading via the configured uploader.
- */
-public abstract class AgentBase {
-
- /** The logger. */
- protected final Logger logger = LoggingUtils.getLogger(this);
-
- /** Controls the JaCoCo runtime. */
- public final JacocoRuntimeController controller;
-
- /** The agent options. */
- protected AgentOptions options;
-
- private Server server;
-
- /** Constructor. */
- public AgentBase(AgentOptions options) throws IllegalStateException {
- this.options = options;
-
- try {
- controller = new JacocoRuntimeController(RT.getAgent());
- } catch (IllegalStateException e) {
- throw new IllegalStateException(
- "Teamscale Java Profiler not started or there is a conflict with another agent on the classpath.",
- e);
- }
- logger.info("Starting Teamscale Java Profiler for process {} with options: {}",
- ManagementFactory.getRuntimeMXBean().getName(), getOptionsObjectToLog());
- if (options.getHttpServerPort() != null) {
- try {
- initServer();
- } catch (Exception e) {
- logger.error("Could not start http server on port " + options.getHttpServerPort()
- + ". Please check if the port is blocked.");
- throw new IllegalStateException("Control server not started.", e);
- }
- }
- }
-
-
-
- /**
- * Lazily generated string representation of the command line arguments to print to the log.
- */
- private Object getOptionsObjectToLog() {
- return new Object() {
- @Override
- public String toString() {
- if (options.shouldObfuscateSecurityRelatedOutputs()) {
- return options.getObfuscatedOptionsString();
- }
- return options.getOriginalOptionsString();
- }
- };
- }
-
- /**
- * Starts the http server, which waits for information about started and finished tests.
- */
- private void initServer() throws Exception {
- logger.info("Listening for test events on port {}.", options.getHttpServerPort());
-
- // Jersey Implementation
- ServletContextHandler handler = buildUsingResourceConfig();
- QueuedThreadPool threadPool = new QueuedThreadPool();
- threadPool.setMaxThreads(10);
- threadPool.setDaemon(true);
-
- // Create a server instance and set the thread pool
- server = new Server(threadPool);
- // Create a server connector, set the port and add it to the server
- ServerConnector connector = new ServerConnector(server);
- connector.setPort(options.getHttpServerPort());
- server.addConnector(connector);
- server.setHandler(handler);
- server.start();
- }
-
- private ServletContextHandler buildUsingResourceConfig() {
- ServletContextHandler handler = new ServletContextHandler(ServletContextHandler.NO_SESSIONS);
- handler.setContextPath("/");
-
- ResourceConfig resourceConfig = initResourceConfig();
- handler.addServlet(new ServletHolder(new ServletContainer(resourceConfig)), "/*");
- return handler;
- }
-
- /**
- * Initializes the {@link ResourceConfig} needed for the Jetty + Jersey Server
- */
- protected abstract ResourceConfig initResourceConfig();
-
- /**
- * Registers a shutdown hook that stops the timer and dumps coverage a final time.
- */
- void registerShutdownHook() {
- Runtime.getRuntime().addShutdownHook(new Thread(() -> {
- try {
- logger.info("Teamscale Java Profiler is shutting down...");
- stopServer();
- prepareShutdown();
- logger.info("Teamscale Java Profiler successfully shut down.");
- } catch (Exception e) {
- logger.error("Exception during profiler shutdown.", e);
- } finally {
- // Try to flush logging resources also in case of an exception during shutdown
- PreMain.closeLoggingResources();
- }
- }));
- }
-
- /** Stop the http server if it's running */
- void stopServer() {
- if (options.getHttpServerPort() != null) {
- try {
- server.stop();
- } catch (Exception e) {
- logger.error("Could not stop server so it is killed now.", e);
- } finally {
- server.destroy();
- }
- }
- }
-
- /** Called when the shutdown hook is triggered. */
- protected void prepareShutdown() {
- // Template method to be overridden by subclasses.
- }
-
- /**
- * Dumps the current execution data, converts it, writes it to the output
- * directory defined in {@link #options} and uploads it if an uploader is
- * configured. Logs any errors, never throws an exception.
- */
- public abstract void dumpReport();
-
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/AgentResource.java b/agent/src/main/java/com/teamscale/jacoco/agent/AgentResource.java
deleted file mode 100644
index 51684e739..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/AgentResource.java
+++ /dev/null
@@ -1,41 +0,0 @@
-package com.teamscale.jacoco.agent;
-
-import javax.ws.rs.POST;
-import javax.ws.rs.Path;
-import javax.ws.rs.core.Response;
-
-/**
- * The resource of the Jersey + Jetty http server holding all the endpoints specific for the {@link Agent}.
- */
-@Path("/")
-public class AgentResource extends ResourceBase {
-
- private static Agent agent;
-
- /**
- * Static setter to inject the {@link Agent} to the resource.
- */
- public static void setAgent(Agent agent) {
- AgentResource.agent = agent;
- ResourceBase.agentBase = agent;
- }
-
- /** Handles dumping a XML coverage report for coverage collected until now. */
- @POST
- @Path("/dump")
- public Response handleDump() {
- logger.debug("Dumping report triggered via HTTP request");
- agent.dumpReport();
- return Response.noContent().build();
- }
-
- /** Handles resetting of coverage. */
- @POST
- @Path("/reset")
- public Response handleReset() {
- logger.debug("Resetting coverage triggered via HTTP request");
- agent.controller.reset();
- return Response.noContent().build();
- }
-
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/DelayedLogger.java b/agent/src/main/java/com/teamscale/jacoco/agent/DelayedLogger.java
deleted file mode 100644
index 574389c58..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/DelayedLogger.java
+++ /dev/null
@@ -1,70 +0,0 @@
-package com.teamscale.jacoco.agent;
-
-import com.teamscale.report.util.ILogger;
-import org.slf4j.Logger;
-
-import java.util.ArrayList;
-import java.util.List;
-
-/**
- * A logger that buffers logs in memory and writes them to the actual logger at a later point. This is needed when stuff
- * needs to be logged before the actual logging framework is initialized.
- */
-public class DelayedLogger implements ILogger {
-
- /** List of log actions that will be executed once the logger is initialized. */
- private final List logActions = new ArrayList<>();
-
- @Override
- public void debug(String message) {
- logActions.add(logger -> logger.debug(message));
- }
-
- @Override
- public void info(String message) {
- logActions.add(logger -> logger.info(message));
- }
-
- @Override
- public void warn(String message) {
- logActions.add(logger -> logger.warn(message));
- }
-
- @Override
- public void warn(String message, Throwable throwable) {
- logActions.add(logger -> logger.warn(message, throwable));
- }
-
- @Override
- public void error(Throwable throwable) {
- logActions.add(logger -> logger.error(throwable.getMessage(), throwable));
- }
-
- @Override
- public void error(String message, Throwable throwable) {
- logActions.add(logger -> logger.error(message, throwable));
- }
-
- /**
- * Logs an error and also writes the message to {@link System#err} to ensure the message is even logged in case
- * setting up the logger itself fails for some reason (see TS-23151).
- */
- public void errorAndStdErr(String message, Throwable throwable) {
- System.err.println(message);
- logActions.add(logger -> logger.error(message, throwable));
- }
-
- /** Writes the logs to the given slf4j logger. */
- public void logTo(Logger logger) {
- logActions.forEach(action -> action.log(logger));
- }
-
- /** An action to be executed on a logger. */
- private interface ILoggerAction {
-
- /** Executes the action on the given logger. */
- void log(Logger logger);
-
- }
-}
-
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/GenericExceptionMapper.java b/agent/src/main/java/com/teamscale/jacoco/agent/GenericExceptionMapper.java
deleted file mode 100644
index 76c1d043f..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/GenericExceptionMapper.java
+++ /dev/null
@@ -1,20 +0,0 @@
-package com.teamscale.jacoco.agent;
-
-import javax.ws.rs.core.MediaType;
-import javax.ws.rs.core.Response;
-import javax.ws.rs.ext.ExceptionMapper;
-
-/**
- * Generates a {@link Response} for an exception.
- */
-@javax.ws.rs.ext.Provider
-public class GenericExceptionMapper implements ExceptionMapper {
-
- @Override
- public Response toResponse(Throwable e) {
- Response.ResponseBuilder errorResponse = Response.status(Response.Status.INTERNAL_SERVER_ERROR);
- errorResponse.type(MediaType.TEXT_PLAIN_TYPE);
- errorResponse.entity("Message: " + e.getMessage());
- return errorResponse.build();
- }
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/JacocoRuntimeController.java b/agent/src/main/java/com/teamscale/jacoco/agent/JacocoRuntimeController.java
deleted file mode 100644
index 1ad9b2146..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/JacocoRuntimeController.java
+++ /dev/null
@@ -1,139 +0,0 @@
-/*-------------------------------------------------------------------------+
-| |
-| Copyright (c) 2009-2018 CQSE GmbH |
-| |
-+-------------------------------------------------------------------------*/
-package com.teamscale.jacoco.agent;
-
-import com.teamscale.report.jacoco.dump.Dump;
-import org.jacoco.agent.rt.IAgent;
-import org.jacoco.agent.rt.RT;
-import org.jacoco.core.data.ExecutionDataReader;
-import org.jacoco.core.data.ExecutionDataStore;
-import org.jacoco.core.data.ISessionInfoVisitor;
-import org.jacoco.core.data.SessionInfo;
-
-import java.io.ByteArrayInputStream;
-import java.io.File;
-import java.io.FileOutputStream;
-import java.io.IOException;
-
-/**
- * Wrapper around JaCoCo's {@link RT} runtime interface.
- *
- * Can be used if the calling code is run in the same JVM as the agent is attached to.
- */
-public class JacocoRuntimeController {
-
- /** Indicates a failed dump. */
- public static class DumpException extends Exception {
-
- /** Serialization ID. */
- private static final long serialVersionUID = 1L;
-
- /** Constructor. */
- public DumpException(String message, Throwable cause) {
- super(message, cause);
- }
-
- }
-
- /** JaCoCo's {@link RT} agent instance */
- private final IAgent agent;
-
- /** Constructor. */
- public JacocoRuntimeController(IAgent agent) {
- this.agent = agent;
- }
-
- /**
- * Dumps execution data and resets it.
- *
- * @throws DumpException if dumping fails. This should never happen in real life. Dumping should simply be retried
- * later if this ever happens.
- */
- public Dump dumpAndReset() throws DumpException {
- byte[] binaryData = agent.getExecutionData(true);
-
- try (ByteArrayInputStream inputStream = new ByteArrayInputStream(binaryData)) {
- ExecutionDataReader reader = new ExecutionDataReader(inputStream);
-
- ExecutionDataStore store = new ExecutionDataStore();
- reader.setExecutionDataVisitor(store::put);
-
- SessionInfoVisitor sessionInfoVisitor = new SessionInfoVisitor();
- reader.setSessionInfoVisitor(sessionInfoVisitor);
-
- reader.read();
- return new Dump(sessionInfoVisitor.sessionInfo, store);
- } catch (IOException e) {
- throw new DumpException("should never happen for the ByteArrayInputStream", e);
- }
- }
-
- /**
- * Dumps execution data to the given file and resets it afterwards.
- */
- public void dumpToFileAndReset(File file) throws IOException {
- byte[] binaryData = agent.getExecutionData(true);
-
- try (FileOutputStream outputStream = new FileOutputStream(file, true)) {
- outputStream.write(binaryData);
- }
- }
-
-
- /**
- * Dumps execution data to a file and resets it.
- *
- * @throws DumpException if dumping fails. This should never happen in real life. Dumping should simply be retried
- * later if this ever happens.
- */
- public void dump() throws DumpException {
- try {
- agent.dump(true);
- } catch (IOException e) {
- throw new DumpException(e.getMessage(), e);
- }
- }
-
- /** Resets already collected coverage. */
- public void reset() {
- agent.reset();
- }
-
- /** Returns the current sessionId. */
- public String getSessionId() {
- return agent.getSessionId();
- }
-
- /**
- * Sets the current sessionId of the agent that can be used to identify which coverage is recorded from now on.
- */
- public void setSessionId(String sessionId) {
- agent.setSessionId(sessionId);
- }
-
- /** Unsets the session ID so that coverage collected from now on is not attributed to the previous test. */
- public void resetSessionId() {
- agent.setSessionId("");
- }
-
- /**
- * Receives and stores a {@link SessionInfo}. Has a fallback dummy session in case nothing is received.
- */
- private static class SessionInfoVisitor implements ISessionInfoVisitor {
-
- /** The received session info or a dummy. */
- public SessionInfo sessionInfo = new SessionInfo("dummysession", System.currentTimeMillis(),
- System.currentTimeMillis());
-
- /** {@inheritDoc} */
- @Override
- public void visitSessionInfo(SessionInfo info) {
- this.sessionInfo = info;
- }
-
- }
-
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/LenientCoverageTransformer.java b/agent/src/main/java/com/teamscale/jacoco/agent/LenientCoverageTransformer.java
deleted file mode 100644
index 51c65737b..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/LenientCoverageTransformer.java
+++ /dev/null
@@ -1,50 +0,0 @@
-package com.teamscale.jacoco.agent;
-
-import org.jacoco.agent.rt.internal_29a6edd.CoverageTransformer;
-import org.jacoco.agent.rt.internal_29a6edd.core.runtime.AgentOptions;
-import org.jacoco.agent.rt.internal_29a6edd.core.runtime.IRuntime;
-import org.slf4j.Logger;
-
-import java.lang.instrument.IllegalClassFormatException;
-import java.security.ProtectionDomain;
-
-/**
- * A class file transformer which delegates to the JaCoCo {@link CoverageTransformer} to do the actual instrumentation,
- * but treats instrumentation errors e.g. due to unsupported class file versions more lenient by only logging them, but
- * not bailing out completely. Those unsupported classes will not be instrumented and will therefore not be contained in
- * the collected coverage report.
- */
-public class LenientCoverageTransformer extends CoverageTransformer {
-
- private final Logger logger;
-
- public LenientCoverageTransformer(IRuntime runtime, AgentOptions options, Logger logger) {
- // The coverage transformer only uses the logger to print an error when the instrumentation fails.
- // We want to show our more specific error message instead, so we only log this for debugging at trace.
- super(runtime, options, e -> logger.trace(e.getMessage(), e));
- this.logger = logger;
- }
-
- @Override
- public byte[] transform(ClassLoader loader, String classname, Class> classBeingRedefined,
- ProtectionDomain protectionDomain,
- byte[] classfileBuffer) {
- try {
- return super.transform(loader, classname, classBeingRedefined, protectionDomain, classfileBuffer);
- } catch (IllegalClassFormatException e) {
- logger.error(
- "Failed to instrument " + classname + ". File will be skipped from instrumentation. " +
- "No coverage will be collected for it. Exclude the file from the instrumentation or try " +
- "updating the Teamscale Java Profiler if the file should actually be instrumented. (Cause: {})",
- getRootCauseMessage(e));
- return null;
- }
- }
-
- private static String getRootCauseMessage(Throwable e) {
- if (e.getCause() != null) {
- return getRootCauseMessage(e.getCause());
- }
- return e.getMessage();
- }
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/Main.java b/agent/src/main/java/com/teamscale/jacoco/agent/Main.java
deleted file mode 100644
index 20c92dae8..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/Main.java
+++ /dev/null
@@ -1,84 +0,0 @@
-package com.teamscale.jacoco.agent;
-
-import com.beust.jcommander.JCommander;
-import com.beust.jcommander.JCommander.Builder;
-import com.beust.jcommander.Parameter;
-import com.beust.jcommander.ParameterException;
-import com.teamscale.client.StringUtils;
-import com.teamscale.jacoco.agent.commandline.Validator;
-import com.teamscale.jacoco.agent.convert.ConvertCommand;
-import com.teamscale.jacoco.agent.logging.LoggingUtils;
-import com.teamscale.jacoco.agent.util.AgentUtils;
-import org.jacoco.core.JaCoCo;
-import org.slf4j.Logger;
-
-/** Provides a command line interface for interacting with JaCoCo. */
-public class Main {
-
- /** The logger. */
- private final Logger logger = LoggingUtils.getLogger(this);
-
- /** The default arguments that will always be parsed. */
- private final DefaultArguments defaultArguments = new DefaultArguments();
-
- /** The arguments for the one-time conversion process. */
- private final ConvertCommand command = new ConvertCommand();
-
- /** Entry point. */
- public static void main(String[] args) throws Exception {
- new Main().parseCommandLineAndRun(args);
- }
-
- /**
- * Parses the given command line arguments. Exits the program or throws an exception if the arguments are not valid.
- * Then runs the specified command.
- */
- private void parseCommandLineAndRun(String[] args) throws Exception {
- Builder builder = createJCommanderBuilder();
- JCommander jCommander = builder.build();
-
- try {
- jCommander.parse(args);
- } catch (ParameterException e) {
- handleInvalidCommandLine(jCommander, e.getMessage());
- }
-
- if (defaultArguments.help) {
- System.out.println(
- "Teamscale Java Profiler " + AgentUtils.VERSION + " compiled against JaCoCo " + JaCoCo.VERSION);
- jCommander.usage();
- return;
- }
-
- Validator validator = command.validate();
- if (!validator.isValid()) {
- handleInvalidCommandLine(jCommander, StringUtils.LINE_FEED + validator.getErrorMessage());
- }
-
- logger.info(
- "Starting Teamscale Java Profiler " + AgentUtils.VERSION + " compiled against JaCoCo " + JaCoCo.VERSION);
- command.run();
- }
-
- /** Creates a builder for a {@link JCommander} object. */
- private Builder createJCommanderBuilder() {
- return JCommander.newBuilder().programName(Main.class.getName()).addObject(defaultArguments).addObject(command);
- }
-
- /** Shows an informative error and help message. Then exits the program. */
- private static void handleInvalidCommandLine(JCommander jCommander, String message) {
- System.err.println("Invalid command line: " + message + StringUtils.LINE_FEED);
- jCommander.usage();
- System.exit(1);
- }
-
- /** Default arguments that may always be provided. */
- private static class DefaultArguments {
-
- /** Shows the help message. */
- @Parameter(names = "--help", help = true, description = "Shows all available command line arguments.")
- private boolean help;
-
- }
-
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/PreMain.java b/agent/src/main/java/com/teamscale/jacoco/agent/PreMain.java
deleted file mode 100644
index 921a115b8..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/PreMain.java
+++ /dev/null
@@ -1,305 +0,0 @@
-package com.teamscale.jacoco.agent;
-
-import com.teamscale.client.FileSystemUtils;
-import com.teamscale.client.HttpUtils;
-import com.teamscale.client.StringUtils;
-import com.teamscale.jacoco.agent.configuration.AgentOptionReceiveException;
-import com.teamscale.jacoco.agent.logging.DebugLogDirectoryPropertyDefiner;
-import com.teamscale.jacoco.agent.logging.LogDirectoryPropertyDefiner;
-import com.teamscale.jacoco.agent.logging.LogToTeamscaleAppender;
-import com.teamscale.jacoco.agent.logging.LoggingUtils;
-import com.teamscale.jacoco.agent.options.AgentOptionParseException;
-import com.teamscale.jacoco.agent.options.AgentOptions;
-import com.teamscale.jacoco.agent.options.AgentOptionsParser;
-import com.teamscale.jacoco.agent.options.FilePatternResolver;
-import com.teamscale.jacoco.agent.options.JacocoAgentOptionsBuilder;
-import com.teamscale.jacoco.agent.options.TeamscaleCredentials;
-import com.teamscale.jacoco.agent.options.TeamscalePropertiesUtils;
-import com.teamscale.jacoco.agent.testimpact.TestwiseCoverageAgent;
-import com.teamscale.jacoco.agent.upload.UploaderException;
-import com.teamscale.jacoco.agent.util.AgentUtils;
-import com.teamscale.report.util.ILogger;
-import kotlin.Pair;
-import org.slf4j.Logger;
-
-import java.io.File;
-import java.io.IOException;
-import java.lang.instrument.Instrumentation;
-import java.lang.management.ManagementFactory;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.nio.file.Paths;
-import java.util.List;
-import java.util.Optional;
-import java.util.stream.Collectors;
-
-import static com.teamscale.jacoco.agent.logging.LoggingUtils.getLoggerContext;
-
-/** Container class for the premain entry point for the agent. */
-public class PreMain {
-
- private static LoggingUtils.LoggingResources loggingResources = null;
-
- /**
- * System property that we use to prevent this agent from being attached to the same VM twice. This can happen if
- * the agent is registered via multiple JVM environment variables and/or the command line at the same time.
- */
- private static final String LOCKING_SYSTEM_PROPERTY = "TEAMSCALE_JAVA_PROFILER_ATTACHED";
-
- /**
- * Environment variable from which to read the config ID to use. This is an ID for a profiler configuration that is
- * stored in Teamscale.
- */
- private static final String CONFIG_ID_ENVIRONMENT_VARIABLE = "TEAMSCALE_JAVA_PROFILER_CONFIG_ID";
-
- /** Environment variable from which to read the config file to use. */
- private static final String CONFIG_FILE_ENVIRONMENT_VARIABLE = "TEAMSCALE_JAVA_PROFILER_CONFIG_FILE";
-
- /** Environment variable from which to read the Teamscale access token. */
- private static final String ACCESS_TOKEN_ENVIRONMENT_VARIABLE = "TEAMSCALE_ACCESS_TOKEN";
-
- /**
- * Entry point for the agent, called by the JVM.
- */
- public static void premain(String options, Instrumentation instrumentation) throws Exception {
- if (System.getProperty(LOCKING_SYSTEM_PROPERTY) != null) {
- return;
- }
- System.setProperty(LOCKING_SYSTEM_PROPERTY, "true");
-
- String environmentConfigId = System.getenv(CONFIG_ID_ENVIRONMENT_VARIABLE);
- String environmentConfigFile = System.getenv(CONFIG_FILE_ENVIRONMENT_VARIABLE);
- if (StringUtils.isEmpty(options) && environmentConfigId == null && environmentConfigFile == null) {
- // profiler was registered globally and no config was set explicitly by the user, thus ignore this process
- // and don't profile anything
- return;
- }
-
- AgentOptions agentOptions = null;
- try {
- Pair> parseResult = getAndApplyAgentOptions(options, environmentConfigId,
- environmentConfigFile);
- agentOptions = parseResult.getFirst();
-
- // After parsing everything and configuring logging, we now
- // can throw the caught exceptions.
- for (Exception exception : parseResult.getSecond()) {
- throw exception;
- }
- } catch (AgentOptionParseException e) {
- getLoggerContext().getLogger(PreMain.class).error(e.getMessage(), e);
-
- // Flush logs to Teamscale, if configured.
- closeLoggingResources();
-
- // Unregister the profiler from Teamscale.
- if (agentOptions != null && agentOptions.configurationViaTeamscale != null) {
- agentOptions.configurationViaTeamscale.unregisterProfiler();
- }
-
- throw e;
- } catch (AgentOptionReceiveException e) {
- // When Teamscale is not available, we don't want to fail hard to still allow for testing even if no
- // coverage is collected (see TS-33237)
- return;
- }
-
- Logger logger = LoggingUtils.getLogger(Agent.class);
-
- logger.info("Teamscale Java profiler version " + AgentUtils.VERSION);
- logger.info("Starting JaCoCo's agent");
- JacocoAgentOptionsBuilder agentBuilder = new JacocoAgentOptionsBuilder(agentOptions);
- JaCoCoPreMain.premain(agentBuilder.createJacocoAgentOptions(), instrumentation, logger);
-
- if (agentOptions.configurationViaTeamscale != null) {
- agentOptions.configurationViaTeamscale.startHeartbeatThreadAndRegisterShutdownHook();
- }
- AgentBase agent = createAgent(agentOptions, instrumentation);
- agent.registerShutdownHook();
- }
-
- private static Pair> getAndApplyAgentOptions(String options,
- String environmentConfigId,
- String environmentConfigFile) throws AgentOptionParseException, IOException, AgentOptionReceiveException {
-
- DelayedLogger delayedLogger = new DelayedLogger();
- List javaAgents = ManagementFactory.getRuntimeMXBean().getInputArguments().stream().filter(
- s -> s.contains("-javaagent")).collect(Collectors.toList());
- // We allow multiple instances of the teamscale-jacoco-agent as we ensure with the #LOCKING_SYSTEM_PROPERTY to only use it once
- List differentAgents = javaAgents.stream()
- .filter(javaAgent -> !javaAgent.contains("teamscale-jacoco-agent.jar")).collect(
- Collectors.toList());
-
- if (!differentAgents.isEmpty()) {
- delayedLogger.warn(
- "Using multiple java agents could interfere with coverage recording: " +
- String.join(", ", differentAgents));
- }
- if (!javaAgents.get(0).contains("teamscale-jacoco-agent.jar")) {
- delayedLogger.warn("For best results consider registering the Teamscale Java Profiler first.");
- }
-
- TeamscaleCredentials credentials = TeamscalePropertiesUtils.parseCredentials();
- if (credentials == null) {
- // As many users still don't use the installer based setup, this log message will be shown in almost every log.
- // We use a debug log, as this message can be confusing for customers that think a teamscale.properties file is synonymous with a config file.
- delayedLogger.debug(
- "No explicit teamscale.properties file given. Looking for Teamscale credentials in a config file or via a command line argument. This is expected unless the installer based setup was used.");
- }
-
- String environmentAccessToken = System.getenv(ACCESS_TOKEN_ENVIRONMENT_VARIABLE);
-
- Pair> parseResult;
- AgentOptions agentOptions;
- try {
- parseResult = AgentOptionsParser.parse(
- options, environmentConfigId, environmentConfigFile, credentials, environmentAccessToken,
- delayedLogger);
- agentOptions = parseResult.getFirst();
- } catch (AgentOptionParseException e) {
- try (LoggingUtils.LoggingResources ignored = initializeFallbackLogging(options, delayedLogger)) {
- delayedLogger.errorAndStdErr("Failed to parse agent options: " + e.getMessage(), e);
- attemptLogAndThrow(delayedLogger);
- throw e;
- }
- } catch (AgentOptionReceiveException e) {
- try (LoggingUtils.LoggingResources ignored = initializeFallbackLogging(options, delayedLogger)) {
- delayedLogger.errorAndStdErr(
- e.getMessage() + " The application should start up normally, but NO coverage will be collected! Check the log file for details.",
- e);
- attemptLogAndThrow(delayedLogger);
- throw e;
- }
- }
-
- initializeLogging(agentOptions, delayedLogger);
- Logger logger = LoggingUtils.getLogger(Agent.class);
- delayedLogger.logTo(logger);
- HttpUtils.setShouldValidateSsl(agentOptions.shouldValidateSsl());
-
- return parseResult;
- }
-
- private static void attemptLogAndThrow(DelayedLogger delayedLogger) {
- // We perform actual logging output after writing to console to
- // ensure the console is reached even in case of logging issues
- // (see TS-23151). We use the Agent class here (same as below)
- Logger logger = LoggingUtils.getLogger(Agent.class);
- delayedLogger.logTo(logger);
- }
-
- /** Initializes logging during {@link #premain(String, Instrumentation)} and also logs the log directory. */
- private static void initializeLogging(AgentOptions agentOptions, DelayedLogger logger) throws IOException {
- if (agentOptions.isDebugLogging()) {
- initializeDebugLogging(agentOptions, logger);
- } else {
- loggingResources = LoggingUtils.initializeLogging(agentOptions.getLoggingConfig());
- logger.info("Logging to " + new LogDirectoryPropertyDefiner().getPropertyValue());
- }
-
- if (agentOptions.getTeamscaleServerOptions().isConfiguredForServerConnection()) {
- if (LogToTeamscaleAppender.addTeamscaleAppenderTo(getLoggerContext(), agentOptions)) {
- logger.info("Logs are being forwarded to Teamscale at " + agentOptions.getTeamscaleServerOptions().url);
- }
- }
- }
-
- /** Closes the opened logging contexts. */
- static void closeLoggingResources() {
- loggingResources.close();
- }
-
- /**
- * Returns in instance of the agent that was configured. Either an agent with interval based line-coverage dump or
- * the HTTP server is used.
- */
- private static AgentBase createAgent(AgentOptions agentOptions,
- Instrumentation instrumentation) throws UploaderException, IOException {
- if (agentOptions.useTestwiseCoverageMode()) {
- return TestwiseCoverageAgent.create(agentOptions);
- } else {
- return new Agent(agentOptions, instrumentation);
- }
- }
-
- /**
- * Initializes debug logging during {@link #premain(String, Instrumentation)} and also logs the log directory if
- * given.
- */
- private static void initializeDebugLogging(AgentOptions agentOptions, DelayedLogger logger) {
- loggingResources = LoggingUtils.initializeDebugLogging(agentOptions.getDebugLogDirectory());
- Path logDirectory = Paths.get(new DebugLogDirectoryPropertyDefiner().getPropertyValue());
- if (FileSystemUtils.isValidPath(logDirectory.toString()) && Files.isWritable(logDirectory)) {
- logger.info("Logging to " + logDirectory);
- } else {
- logger.warn("Could not create " + logDirectory + ". Logging to console only.");
- }
- }
-
- /**
- * Initializes fallback logging in case of an error during the parsing of the options to
- * {@link #premain(String, Instrumentation)} (see TS-23151). This tries to extract the logging configuration and use
- * this and falls back to the default logger.
- */
- private static LoggingUtils.LoggingResources initializeFallbackLogging(String premainOptions,
- DelayedLogger delayedLogger) {
- if (premainOptions == null) {
- return LoggingUtils.initializeDefaultLogging();
- }
- for (String optionPart : premainOptions.split(",")) {
- if (optionPart.startsWith(AgentOptionsParser.DEBUG + "=")) {
- String value = optionPart.split("=", 2)[1];
- boolean debugDisabled = value.equalsIgnoreCase("false");
- boolean debugEnabled = value.equalsIgnoreCase("true");
- if (debugDisabled) {
- continue;
- }
- Path debugLogDirectory = null;
- if (!value.isEmpty() && !debugEnabled) {
- debugLogDirectory = Paths.get(value);
- }
- return LoggingUtils.initializeDebugLogging(debugLogDirectory);
- }
- if (optionPart.startsWith(AgentOptionsParser.LOGGING_CONFIG_OPTION + "=")) {
- return createFallbackLoggerFromConfig(optionPart.split("=", 2)[1], delayedLogger);
- }
-
- if (optionPart.startsWith(AgentOptionsParser.CONFIG_FILE_OPTION + "=")) {
- String configFileValue = optionPart.split("=", 2)[1];
- Optional loggingConfigLine = Optional.empty();
- try {
- File configFile = new FilePatternResolver(delayedLogger).parsePath(
- AgentOptionsParser.CONFIG_FILE_OPTION, configFileValue).toFile();
- loggingConfigLine = FileSystemUtils.readLinesUTF8(configFile).stream()
- .filter(line -> line.startsWith(AgentOptionsParser.LOGGING_CONFIG_OPTION + "="))
- .findFirst();
- } catch (IOException e) {
- delayedLogger.error("Failed to load configuration from " + configFileValue + ": " + e.getMessage(),
- e);
- }
- if (loggingConfigLine.isPresent()) {
- return createFallbackLoggerFromConfig(loggingConfigLine.get().split("=", 2)[1], delayedLogger);
- }
- }
- }
-
- return LoggingUtils.initializeDefaultLogging();
- }
-
- /** Creates a fallback logger using the given config file. */
- private static LoggingUtils.LoggingResources createFallbackLoggerFromConfig(String configLocation,
- ILogger delayedLogger) {
- try {
- return LoggingUtils.initializeLogging(
- new FilePatternResolver(delayedLogger).parsePath(AgentOptionsParser.LOGGING_CONFIG_OPTION,
- configLocation));
- } catch (IOException e) {
- String message = "Failed to load log configuration from location " + configLocation + ": " + e.getMessage();
- delayedLogger.error(message, e);
- // output the message to console as well, as this might
- // otherwise not make it to the user
- System.err.println(message);
- return LoggingUtils.initializeDefaultLogging();
- }
- }
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/ResourceBase.java b/agent/src/main/java/com/teamscale/jacoco/agent/ResourceBase.java
deleted file mode 100644
index fb539824e..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/ResourceBase.java
+++ /dev/null
@@ -1,145 +0,0 @@
-package com.teamscale.jacoco.agent;
-
-import com.teamscale.client.CommitDescriptor;
-import com.teamscale.client.StringUtils;
-import com.teamscale.client.TeamscaleServer;
-import com.teamscale.jacoco.agent.logging.LoggingUtils;
-import com.teamscale.jacoco.agent.testimpact.TestwiseCoverageAgent;
-import com.teamscale.report.testwise.model.RevisionInfo;
-import org.jetbrains.annotations.Contract;
-import org.slf4j.Logger;
-
-import javax.ws.rs.BadRequestException;
-import javax.ws.rs.GET;
-import javax.ws.rs.PUT;
-import javax.ws.rs.Path;
-import javax.ws.rs.Produces;
-import javax.ws.rs.core.MediaType;
-import javax.ws.rs.core.Response;
-import java.util.Optional;
-
-
-/**
- * The resource of the Jersey + Jetty http server holding all the endpoints specific for the {@link AgentBase}.
- */
-public abstract class ResourceBase {
-
- /** The logger. */
- protected final Logger logger = LoggingUtils.getLogger(this);
-
- /**
- * The agentBase inject via {@link AgentResource#setAgent(Agent)} or
- * {@link com.teamscale.jacoco.agent.testimpact.TestwiseCoverageResource#setAgent(TestwiseCoverageAgent)}.
- */
- protected static AgentBase agentBase;
-
- /** Returns the partition for the Teamscale upload. */
- @GET
- @Path("/partition")
- public String getPartition() {
- return Optional.ofNullable(agentBase.options.getTeamscaleServerOptions().partition).orElse("");
- }
-
- /** Returns the upload message for the Teamscale upload. */
- @GET
- @Path("/message")
- public String getMessage() {
- return Optional.ofNullable(agentBase.options.getTeamscaleServerOptions().getMessage())
- .orElse("");
- }
-
- /** Returns revision information for the Teamscale upload. */
- @GET
- @Path("/revision")
- @Produces(MediaType.APPLICATION_JSON)
- public RevisionInfo getRevision() {
- return this.getRevisionInfo();
- }
-
- /** Returns revision information for the Teamscale upload. */
- @GET
- @Path("/commit")
- @Produces(MediaType.APPLICATION_JSON)
- public RevisionInfo getCommit() {
- return this.getRevisionInfo();
- }
-
- /** Handles setting the partition name. */
- @PUT
- @Path("/partition")
- public Response setPartition(String partitionString) {
- String partition = StringUtils.removeDoubleQuotes(partitionString);
- if (partition == null || partition.isEmpty()) {
- handleBadRequest("The new partition name is missing in the request body! Please add it as plain text.");
- }
-
- logger.debug("Changing partition name to " + partition);
- agentBase.dumpReport();
- agentBase.controller.setSessionId(partition);
- agentBase.options.getTeamscaleServerOptions().partition = partition;
- return Response.noContent().build();
- }
-
- /** Handles setting the upload message. */
- @PUT
- @Path("/message")
- public Response setMessage(String messageString) {
- String message = StringUtils.removeDoubleQuotes(messageString);
- if (message == null || message.isEmpty()) {
- handleBadRequest("The new message is missing in the request body! Please add it as plain text.");
- }
-
- agentBase.dumpReport();
- logger.debug("Changing message to " + message);
- agentBase.options.getTeamscaleServerOptions().setMessage(message);
-
- return Response.noContent().build();
- }
-
- /** Handles setting the revision. */
- @PUT
- @Path("/revision")
- public Response setRevision(String revisionString) {
- String revision = StringUtils.removeDoubleQuotes(revisionString);
- if (revision == null || revision.isEmpty()) {
- handleBadRequest("The new revision name is missing in the request body! Please add it as plain text.");
- }
-
- agentBase.dumpReport();
- logger.debug("Changing revision name to " + revision);
- agentBase.options.getTeamscaleServerOptions().revision = revision;
-
- return Response.noContent().build();
- }
-
- /** Handles setting the upload commit. */
- @PUT
- @Path("/commit")
- public Response setCommit(String commitString) {
- String commit = StringUtils.removeDoubleQuotes(commitString);
- if (commit == null || commit.isEmpty()) {
- handleBadRequest("The new upload commit is missing in the request body! Please add it as plain text.");
- }
-
- agentBase.dumpReport();
- agentBase.options.getTeamscaleServerOptions().commit = CommitDescriptor.parse(commit);
-
- return Response.noContent().build();
- }
-
- /** Returns revision information for the Teamscale upload. */
- private RevisionInfo getRevisionInfo() {
- TeamscaleServer server = agentBase.options.getTeamscaleServerOptions();
- return new RevisionInfo(server.commit, server.revision);
- }
-
- /**
- * Handles bad requests to the endpoints.
- */
- @Contract(value = "_ -> fail")
- protected void handleBadRequest(String message) throws BadRequestException {
- logger.error(message);
- throw new BadRequestException(message);
- }
-
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/commandline/Validator.java b/agent/src/main/java/com/teamscale/jacoco/agent/commandline/Validator.java
deleted file mode 100644
index 40b7ce388..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/commandline/Validator.java
+++ /dev/null
@@ -1,68 +0,0 @@
-/*-------------------------------------------------------------------------+
-| |
-| Copyright (c) 2009-2017 CQSE GmbH |
-| |
-+-------------------------------------------------------------------------*/
-package com.teamscale.jacoco.agent.commandline;
-
-import com.teamscale.client.StringUtils;
-import com.teamscale.jacoco.agent.util.Assertions;
-
-import java.util.ArrayList;
-import java.util.List;
-
-/**
- * Helper class to allow for multiple validations to occur.
- */
-public class Validator {
-
- /** The found validation problems in the form of error messages for the user. */
- private final List messages = new ArrayList<>();
-
- /** Runs the given validation routine. */
- public void ensure(ExceptionBasedValidation validation) {
- try {
- validation.validate();
- } catch (Exception | AssertionError e) {
- messages.add(e.getMessage());
- }
- }
-
- /**
- * Interface for a validation routine that throws an exception when it fails.
- */
- @FunctionalInterface
- public interface ExceptionBasedValidation {
-
- /**
- * Throws an {@link Exception} or {@link AssertionError} if the validation fails.
- */
- void validate() throws Exception, AssertionError;
-
- }
-
- /**
- * Checks that the given condition is true or adds the given error message.
- */
- public void isTrue(boolean condition, String message) {
- ensure(() -> Assertions.isTrue(condition, message));
- }
-
- /**
- * Checks that the given condition is false or adds the given error message.
- */
- public void isFalse(boolean condition, String message) {
- ensure(() -> Assertions.isFalse(condition, message));
- }
-
- /** Returns true if the validation succeeded. */
- public boolean isValid() {
- return messages.isEmpty();
- }
-
- /** Returns an error message with all validation problems that were found. */
- public String getErrorMessage() {
- return "- " + String.join(StringUtils.LINE_FEED + "- ", messages);
- }
-
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/CommitInfo.java b/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/CommitInfo.java
deleted file mode 100644
index 82d96d480..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/CommitInfo.java
+++ /dev/null
@@ -1,57 +0,0 @@
-package com.teamscale.jacoco.agent.commit_resolution.git_properties;
-
-import com.teamscale.client.CommitDescriptor;
-import com.teamscale.client.StringUtils;
-
-import java.util.Objects;
-
-/** Hold information regarding a commit. */
-public class CommitInfo {
- /** The revision information (git hash). */
- public String revision;
-
- /** The commit descriptor. */
- public CommitDescriptor commit;
-
- /**
- * If the commit property is set via the teamscale.commit.branch and teamscale.commit.time
- * properties in a git.properties file, this should be preferred to the revision. For details see TS-38561.
- */
- public boolean preferCommitDescriptorOverRevision = false;
-
- /** Constructor. */
- public CommitInfo(String revision, CommitDescriptor commit) {
- this.revision = revision;
- this.commit = commit;
- }
-
- @Override
- public String toString() {
- return commit + "/" + revision;
- }
-
- @Override
- public boolean equals(Object o) {
- if (this == o) {
- return true;
- }
- if (o == null || getClass() != o.getClass()) {
- return false;
- }
- CommitInfo that = (CommitInfo) o;
- return Objects.equals(revision, that.revision) && Objects.equals(commit, that.commit);
- }
-
- @Override
- public int hashCode() {
- return Objects.hash(revision, commit);
- }
-
- /**
- * Returns true if one of or both, revision and commit, are set
- */
- public boolean isEmpty() {
- return StringUtils.isEmpty(revision) && commit == null;
- }
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitMultiProjectPropertiesLocator.java b/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitMultiProjectPropertiesLocator.java
deleted file mode 100644
index cd41823d9..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitMultiProjectPropertiesLocator.java
+++ /dev/null
@@ -1,104 +0,0 @@
-package com.teamscale.jacoco.agent.commit_resolution.git_properties;
-
-import com.teamscale.jacoco.agent.logging.LoggingUtils;
-import com.teamscale.jacoco.agent.options.ProjectAndCommit;
-import com.teamscale.jacoco.agent.upload.teamscale.DelayedTeamscaleMultiProjectUploader;
-import com.teamscale.jacoco.agent.util.DaemonThreadFactory;
-import org.jetbrains.annotations.Nullable;
-import org.jetbrains.annotations.VisibleForTesting;
-import org.slf4j.Logger;
-
-import java.io.File;
-import java.io.IOException;
-import java.time.format.DateTimeFormatter;
-import java.util.List;
-import java.util.concurrent.Executor;
-import java.util.concurrent.Executors;
-
-/**
- * Searches a Jar/War/Ear/... file for a git.properties file in order to enable upload for the commit described therein,
- * e.g. to Teamscale, via a {@link DelayedTeamscaleMultiProjectUploader}. Specifically, this searches for the
- * 'teamscale.project' property specified in each of the discovered 'git.properties' files.
- */
-public class GitMultiProjectPropertiesLocator implements IGitPropertiesLocator {
-
- private final Logger logger = LoggingUtils.getLogger(this);
-
- private final Executor executor;
- private final DelayedTeamscaleMultiProjectUploader uploader;
-
- private final boolean recursiveSearch;
-
- private final @Nullable DateTimeFormatter gitPropertiesCommitTimeFormat;
-
- public GitMultiProjectPropertiesLocator(DelayedTeamscaleMultiProjectUploader uploader, boolean recursiveSearch, @Nullable DateTimeFormatter gitPropertiesCommitTimeFormat) {
- // using a single threaded executor allows this class to be lock-free
- this(uploader, Executors
- .newSingleThreadExecutor(
- new DaemonThreadFactory(GitMultiProjectPropertiesLocator.class,
- "git.properties Jar scanner thread")), recursiveSearch, gitPropertiesCommitTimeFormat);
- }
-
- public GitMultiProjectPropertiesLocator(DelayedTeamscaleMultiProjectUploader uploader, Executor executor,
- boolean recursiveSearch, @Nullable DateTimeFormatter gitPropertiesCommitTimeFormat) {
- this.uploader = uploader;
- this.executor = executor;
- this.recursiveSearch = recursiveSearch;
- this.gitPropertiesCommitTimeFormat = gitPropertiesCommitTimeFormat;
- }
-
- /**
- * Asynchronously searches the given jar file for git.properties files and adds a corresponding uploader to the
- * multi-project uploader.
- */
- @Override
- public void searchFileForGitPropertiesAsync(File file, boolean isJarFile) {
- executor.execute(() -> searchFile(file, isJarFile));
- }
-
- /**
- * Synchronously searches the given jar file for git.properties files and adds a corresponding uploader to the
- * multi-project uploader.
- */
- @VisibleForTesting
- void searchFile(File file, boolean isJarFile) {
- logger.debug("Searching file {} for multiple git.properties", file.toString());
- try {
- List projectAndCommits = GitPropertiesLocatorUtils.getProjectRevisionsFromGitProperties(
- file,
- isJarFile,
- recursiveSearch, gitPropertiesCommitTimeFormat);
- if (projectAndCommits.isEmpty()) {
- logger.debug("No git.properties file found in {}", file);
- return;
- }
-
- for (ProjectAndCommit projectAndCommit : projectAndCommits) {
- // this code only runs when 'teamscale-project' is not given via the agent properties,
- // i.e., a multi-project upload is being attempted.
- // Therefore, we expect to find both the project (teamscale.project) and the revision
- // (git.commit.id) in the git.properties file.
- if (projectAndCommit.getProject() == null || projectAndCommit.getCommitInfo() == null) {
- logger.debug(
- "Found inconsistent git.properties file: the git.properties file in {} either does not specify the" +
- " Teamscale project ({}) property, or does not specify the commit " +
- "({}, {} + {}, or {} + {})." +
- " Will skip this git.properties file and try to continue with the other ones that were found during discovery.",
- file, GitPropertiesLocatorUtils.GIT_PROPERTIES_TEAMSCALE_PROJECT,
- GitPropertiesLocatorUtils.GIT_PROPERTIES_GIT_COMMIT_ID,
- GitPropertiesLocatorUtils.GIT_PROPERTIES_GIT_BRANCH,
- GitPropertiesLocatorUtils.GIT_PROPERTIES_GIT_COMMIT_TIME,
- GitPropertiesLocatorUtils.GIT_PROPERTIES_TEAMSCALE_COMMIT_BRANCH,
- GitPropertiesLocatorUtils.GIT_PROPERTIES_TEAMSCALE_COMMIT_TIME);
- continue;
- }
- logger.debug("Found git.properties file in {} and found Teamscale project {} and revision {}", file,
- projectAndCommit.getProject(), projectAndCommit.getCommitInfo());
- uploader.addTeamscaleProjectAndCommit(file, projectAndCommit);
- }
- } catch (IOException | InvalidGitPropertiesException e) {
- logger.error("Error during asynchronous search for git.properties in {}", file, e);
- }
- }
-
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitPropertiesLocatingTransformer.java b/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitPropertiesLocatingTransformer.java
deleted file mode 100644
index 2b430047e..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitPropertiesLocatingTransformer.java
+++ /dev/null
@@ -1,87 +0,0 @@
-package com.teamscale.jacoco.agent.commit_resolution.git_properties;
-
-import com.teamscale.client.StringUtils;
-import com.teamscale.jacoco.agent.logging.LoggingUtils;
-import com.teamscale.report.util.ClasspathWildcardIncludeFilter;
-import kotlin.Pair;
-import org.slf4j.Logger;
-
-import java.io.File;
-import java.lang.instrument.ClassFileTransformer;
-import java.net.URL;
-import java.security.CodeSource;
-import java.security.ProtectionDomain;
-import java.util.Set;
-import java.util.concurrent.ConcurrentSkipListSet;
-
-/**
- * {@link ClassFileTransformer} that doesn't change the loaded classes but searches their corresponding Jar/War/Ear/...
- * files for a git.properties file.
- */
-public class GitPropertiesLocatingTransformer implements ClassFileTransformer {
-
- private final Logger logger = LoggingUtils.getLogger(this);
- private final Set seenJars = new ConcurrentSkipListSet<>();
- private final IGitPropertiesLocator locator;
- private final ClasspathWildcardIncludeFilter locationIncludeFilter;
-
- public GitPropertiesLocatingTransformer(IGitPropertiesLocator locator,
- ClasspathWildcardIncludeFilter locationIncludeFilter) {
- this.locator = locator;
- this.locationIncludeFilter = locationIncludeFilter;
- }
-
- @Override
- public byte[] transform(ClassLoader classLoader, String className, Class> aClass,
- ProtectionDomain protectionDomain, byte[] classFileContent) {
- if (protectionDomain == null) {
- // happens for e.g. java.lang. We can ignore these classes
- return null;
- }
-
- if (StringUtils.isEmpty(className) || !locationIncludeFilter.isIncluded(className)) {
- // only search in jar files of included classes
- return null;
- }
-
- try {
- CodeSource codeSource = protectionDomain.getCodeSource();
- if (codeSource == null || codeSource.getLocation() == null) {
- // unknown when this can happen, we suspect when code is generated at runtime
- // but there's nothing else we can do here in either case.
- // codeSource.getLocation() is null e.g. when executing Pixelitor with Java14 for class sun/reflect/misc/Trampoline
- logger.debug("Could not locate code source for class {}. Skipping git.properties search for this class",
- className);
- return null;
- }
-
- URL jarOrClassFolderUrl = codeSource.getLocation();
- Pair searchRoot = GitPropertiesLocatorUtils.extractGitPropertiesSearchRoot(
- jarOrClassFolderUrl);
- if (searchRoot == null || searchRoot.getFirst() == null) {
- logger.warn("Not searching location for git.properties with unknown protocol or extension {}." +
- " If this location contains your git.properties, please report this warning as a" +
- " bug to CQSE. In that case, auto-discovery of git.properties will not work.",
- jarOrClassFolderUrl);
- return null;
- }
-
- if (hasLocationAlreadyBeenSearched(searchRoot.getFirst())) {
- return null;
- }
-
- logger.debug("Scheduling asynchronous search for git.properties in {}", searchRoot);
- locator.searchFileForGitPropertiesAsync(searchRoot.getFirst(), searchRoot.getSecond());
- } catch (Throwable e) {
- // we catch Throwable to be sure that we log all errors as anything thrown from this method is
- // silently discarded by the JVM
- logger.error("Failed to process class {} in search of git.properties", className, e);
- }
- return null;
- }
-
- private boolean hasLocationAlreadyBeenSearched(File location) {
- return !seenJars.add(location.toString());
- }
-
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitPropertiesLocatorUtils.java b/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitPropertiesLocatorUtils.java
deleted file mode 100644
index 0d4080b06..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitPropertiesLocatorUtils.java
+++ /dev/null
@@ -1,462 +0,0 @@
-package com.teamscale.jacoco.agent.commit_resolution.git_properties;
-
-import com.teamscale.client.CommitDescriptor;
-import com.teamscale.client.FileSystemUtils;
-import com.teamscale.client.StringUtils;
-import com.teamscale.jacoco.agent.options.ProjectAndCommit;
-import com.teamscale.report.util.BashFileSkippingInputStream;
-import kotlin.Pair;
-import org.jetbrains.annotations.NotNull;
-import org.jetbrains.annotations.Nullable;
-
-import java.io.File;
-import java.io.IOException;
-import java.io.InputStream;
-import java.lang.reflect.InvocationTargetException;
-import java.lang.reflect.Method;
-import java.net.URISyntaxException;
-import java.net.URL;
-import java.nio.file.Files;
-import java.nio.file.Paths;
-import java.time.ZonedDateTime;
-import java.time.format.DateTimeFormatter;
-import java.time.format.DateTimeFormatterBuilder;
-import java.time.format.DateTimeParseException;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Properties;
-import java.util.jar.JarEntry;
-import java.util.jar.JarInputStream;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-
-/** Utility methods to extract certain properties from git.properties files in archives and folders. */
-public class GitPropertiesLocatorUtils {
-
- /** Name of the git.properties file. */
- public static final String GIT_PROPERTIES_FILE_NAME = "git.properties";
-
- /** The git.properties key that holds the commit time. */
- public static final String GIT_PROPERTIES_GIT_COMMIT_TIME = "git.commit.time";
-
- /** The git.properties key that holds the commit branch. */
- public static final String GIT_PROPERTIES_GIT_BRANCH = "git.branch";
-
- /** The git.properties key that holds the commit hash. */
- public static final String GIT_PROPERTIES_GIT_COMMIT_ID = "git.commit.id";
-
- /**
- * Alternative git.properties key that might also hold the commit hash, depending on the Maven git-commit-id plugin
- * configuration.
- */
- public static final String GIT_PROPERTIES_GIT_COMMIT_ID_FULL = "git.commit.id.full";
-
- /**
- * You can provide a teamscale timestamp in git.properties files to overwrite the revision. See TS-38561.
- */
- public static final String GIT_PROPERTIES_TEAMSCALE_COMMIT_BRANCH = "teamscale.commit.branch";
-
- /**
- * You can provide a teamscale timestamp in git.properties files to overwrite the revision. See TS-38561.
- */
- public static final String GIT_PROPERTIES_TEAMSCALE_COMMIT_TIME = "teamscale.commit.time";
-
- /** The git.properties key that holds the Teamscale project name. */
- public static final String GIT_PROPERTIES_TEAMSCALE_PROJECT = "teamscale.project";
-
- /** Matches the path to the jar file in a jar:file: URL in regex group 1. */
- private static final Pattern JAR_URL_REGEX = Pattern.compile("jar:(?:file|nested):(.*?)!.*",
- Pattern.CASE_INSENSITIVE);
-
- private static final Pattern NESTED_JAR_REGEX = Pattern.compile("[jwea]ar:file:(.*?)\\*(.*)",
- Pattern.CASE_INSENSITIVE);
-
- /**
- * Defined in GitCommitIdMojo
- */
- private static final String GIT_PROPERTIES_DEFAULT_MAVEN_DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ssXXX";
-
- /**
- * Defined in GitPropertiesPlugin
- */
- private static final String GIT_PROPERTIES_DEFAULT_GRADLE_DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ssZ";
-
- /**
- * Reads the git SHA1 and branch and timestamp from the given jar file's git.properties and builds a commit
- * descriptor out of it. If no git.properties file can be found, returns null.
- *
- * @throws IOException If reading the jar file fails.
- * @throws InvalidGitPropertiesException If a git.properties file is found but it is malformed.
- */
- public static List getCommitInfoFromGitProperties(File file, boolean isJarFile,
- boolean recursiveSearch,
- @Nullable DateTimeFormatter gitPropertiesCommitTimeFormat)
- throws IOException, InvalidGitPropertiesException {
- List> entriesWithProperties = GitPropertiesLocatorUtils.findGitPropertiesInFile(file,
- isJarFile, recursiveSearch);
- List result = new ArrayList<>();
-
- for (Pair entryWithProperties : entriesWithProperties) {
- String entry = entryWithProperties.getFirst();
- Properties properties = entryWithProperties.getSecond();
-
- CommitInfo commitInfo = GitPropertiesLocatorUtils.getCommitInfoFromGitProperties(properties, entry, file,
- gitPropertiesCommitTimeFormat);
- result.add(commitInfo);
- }
-
- return result;
- }
-
- /**
- * Tries to extract a file system path to a search root for the git.properties search. A search root is either a
- * file system folder or a Jar file. If no such path can be extracted, returns null.
- *
- * @throws URISyntaxException under certain circumstances if parsing the URL fails. This should be treated the same
- * as a null search result but the exception is preserved so it can be logged.
- */
- public static Pair extractGitPropertiesSearchRoot(
- URL jarOrClassFolderUrl) throws URISyntaxException, IOException, NoSuchMethodException,
- IllegalAccessException, InvocationTargetException {
- String protocol = jarOrClassFolderUrl.getProtocol().toLowerCase();
- switch (protocol) {
- case "file":
- File jarOrClassFolderFile = new File(jarOrClassFolderUrl.toURI());
- if (jarOrClassFolderFile.isDirectory() || isJarLikeFile(jarOrClassFolderUrl.getPath())) {
- return new Pair<>(new File(jarOrClassFolderUrl.toURI()), !jarOrClassFolderFile.isDirectory());
- }
- break;
- case "jar":
- // Used e.g. by Spring Boot. Example: jar:file:/home/k/demo.jar!/BOOT-INF/classes!/
- Matcher jarMatcher = JAR_URL_REGEX.matcher(jarOrClassFolderUrl.toString());
- if (jarMatcher.matches()) {
- return new Pair<>(new File(jarMatcher.group(1)), true);
- }
- // Intentionally no break to handle ear and war files
- case "war":
- case "ear":
- // Used by some web applications and potentially fat jars.
- // Example: war:file:/Users/example/apache-tomcat/webapps/demo.war*/WEB-INF/lib/demoLib-1.0-SNAPSHOT.jar
- Matcher nestedMatcher = NESTED_JAR_REGEX.matcher(jarOrClassFolderUrl.toString());
- if (nestedMatcher.matches()) {
- return new Pair<>(new File(nestedMatcher.group(1)), true);
- }
- break;
- case "vfs":
- return getVfsContentFolder(jarOrClassFolderUrl);
- default:
- return null;
- }
- return null;
- }
-
- /**
- * VFS (Virtual File System) protocol is used by JBoss EAP and Wildfly. Example of an URL:
- * vfs:/content/helloworld.war/WEB-INF/classes
- */
- private static Pair getVfsContentFolder(
- URL jarOrClassFolderUrl) throws IOException, NoSuchMethodException, IllegalAccessException, InvocationTargetException {
- // we obtain the URL of a specific class file as input, e.g.,
- // vfs:/content/helloworld.war/WEB-INF/classes
- // Next, we try to extract the artefact URL from it, e.g., vfs:/content/helloworld.war
- String artefactUrl = extractArtefactUrl(jarOrClassFolderUrl);
-
- Object virtualFile = new URL(artefactUrl).openConnection().getContent();
- Class> virtualFileClass = virtualFile.getClass();
- // obtain the physical location of the class file. It is created on demand in /standalone/tmp/vfs
- Method getPhysicalFileMethod = virtualFileClass.getMethod("getPhysicalFile");
- File file = (File) getPhysicalFileMethod.invoke(virtualFile);
- return new Pair<>(file, !file.isDirectory());
- }
-
- /**
- * Extracts the artefact URL (e.g., vfs:/content/helloworld.war/) from the full URL of the class file (e.g.,
- * vfs:/content/helloworld.war/WEB-INF/classes).
- */
- private static String extractArtefactUrl(URL jarOrClassFolderUrl) {
- String url = jarOrClassFolderUrl.getPath().toLowerCase();
- String[] pathSegments = url.split("/");
- StringBuilder artefactUrlBuilder = new StringBuilder("vfs:");
- int segmentIdx = 0;
- while (segmentIdx < pathSegments.length) {
- String segment = pathSegments[segmentIdx];
- artefactUrlBuilder.append(segment);
- artefactUrlBuilder.append("/");
- if (isJarLikeFile(segment)) {
- break;
- }
- segmentIdx += 1;
- }
- if (segmentIdx == pathSegments.length) {
- return url;
- }
- return artefactUrlBuilder.toString();
- }
-
- private static boolean isJarLikeFile(String segment) {
- return StringUtils.endsWithOneOf(
- segment.toLowerCase(), ".jar", ".war", ".ear", ".aar");
- }
-
- /**
- * Reads the 'teamscale.project' property values and the git SHA1s or branch + timestamp from all git.properties
- * files contained in the provided folder or archive file.
- *
- * @throws IOException If reading the jar file fails.
- * @throws InvalidGitPropertiesException If a git.properties file is found but it is malformed.
- */
- public static List getProjectRevisionsFromGitProperties(
- File file, boolean isJarFile, boolean recursiveSearch,
- @Nullable DateTimeFormatter gitPropertiesCommitTimeFormat) throws IOException, InvalidGitPropertiesException {
- List> entriesWithProperties = findGitPropertiesInFile(file, isJarFile,
- recursiveSearch);
- List result = new ArrayList<>();
- for (Pair entryWithProperties : entriesWithProperties) {
- CommitInfo commitInfo = getCommitInfoFromGitProperties(entryWithProperties.getSecond(),
- entryWithProperties.getFirst(), file, gitPropertiesCommitTimeFormat);
- String project = entryWithProperties.getSecond().getProperty(GIT_PROPERTIES_TEAMSCALE_PROJECT);
- if (commitInfo.isEmpty() && StringUtils.isEmpty(project)) {
- throw new InvalidGitPropertiesException(
- "No entry or empty value for both '" + GIT_PROPERTIES_GIT_COMMIT_ID + "'/'" + GIT_PROPERTIES_GIT_COMMIT_ID_FULL +
- "' and '" + GIT_PROPERTIES_TEAMSCALE_PROJECT + "' in " + file + "." +
- "\nContents of " + GIT_PROPERTIES_FILE_NAME + ": " + entryWithProperties.getSecond()
- );
- }
- result.add(new ProjectAndCommit(project, commitInfo));
- }
- return result;
- }
-
- /**
- * Returns pairs of paths to git.properties files and their parsed properties found in the provided folder or
- * archive file. Nested jar files will also be searched recursively if specified.
- */
- public static List> findGitPropertiesInFile(
- File file, boolean isJarFile, boolean recursiveSearch) throws IOException {
- if (isJarFile) {
- return findGitPropertiesInArchiveFile(file, recursiveSearch);
- }
- return findGitPropertiesInDirectoryFile(file, recursiveSearch);
- }
-
- /**
- * Searches for git properties in jar/war/ear/aar files
- */
- private static List> findGitPropertiesInArchiveFile(File file,
- boolean recursiveSearch) throws IOException {
- try (JarInputStream jarStream = new JarInputStream(
- new BashFileSkippingInputStream(Files.newInputStream(file.toPath())))) {
- return findGitPropertiesInArchive(jarStream, file.getName(), recursiveSearch);
- } catch (IOException e) {
- throw new IOException("Reading jar " + file.getAbsolutePath() + " for obtaining commit " +
- "descriptor from git.properties failed", e);
- }
- }
-
- /**
- * Searches for git.properties file in the given folder
- *
- * @param recursiveSearch If enabled, git.properties files will also be searched in jar files
- */
- private static List> findGitPropertiesInDirectoryFile(
- File directoryFile, boolean recursiveSearch) throws IOException {
- List> result = new ArrayList<>(findGitPropertiesInFolder(directoryFile));
-
- if (recursiveSearch) {
- result.addAll(findGitPropertiesInNestedJarFiles(directoryFile));
- }
-
- return result;
- }
-
- /**
- * Finds all jar files in the given folder and searches them recursively for git.properties
- */
- private static List> findGitPropertiesInNestedJarFiles(
- File directoryFile) throws IOException {
- List> result = new ArrayList<>();
- List jarFiles = FileSystemUtils.listFilesRecursively(directoryFile,
- file -> isJarLikeFile(file.getName()));
- for (File jarFile : jarFiles) {
- JarInputStream is = new JarInputStream(Files.newInputStream(jarFile.toPath()));
- String relativeFilePath = directoryFile.getName() + File.separator + directoryFile.toPath()
- .relativize(jarFile.toPath());
- result.addAll(findGitPropertiesInArchive(is, relativeFilePath, true));
- }
- return result;
- }
-
- /**
- * Searches for git.properties files in the given folder
- */
- private static List> findGitPropertiesInFolder(File directoryFile) throws IOException {
- List> result = new ArrayList<>();
- List gitPropertiesFiles = FileSystemUtils.listFilesRecursively(directoryFile,
- file -> file.getName().equalsIgnoreCase(GIT_PROPERTIES_FILE_NAME));
- for (File gitPropertiesFile : gitPropertiesFiles) {
- try (InputStream is = Files.newInputStream(gitPropertiesFile.toPath())) {
- Properties gitProperties = new Properties();
- gitProperties.load(is);
- String relativeFilePath = directoryFile.getName() + File.separator + directoryFile.toPath()
- .relativize(gitPropertiesFile.toPath());
- result.add(new Pair<>(relativeFilePath, gitProperties));
- } catch (IOException e) {
- throw new IOException(
- "Reading directory " + gitPropertiesFile.getAbsolutePath() + " for obtaining commit " +
- "descriptor from git.properties failed", e);
- }
- }
- return result;
- }
-
- /**
- * Returns pairs of paths to git.properties files and their parsed properties found in the provided JarInputStream.
- * Nested jar files will also be searched recursively if specified.
- */
- static List> findGitPropertiesInArchive(
- JarInputStream in, String archiveName, boolean recursiveSearch) throws IOException {
- List> result = new ArrayList<>();
- JarEntry entry;
- boolean isEmpty = true;
-
- while ((entry = in.getNextJarEntry()) != null) {
- isEmpty = false;
- String fullEntryName = archiveName + File.separator + entry.getName();
- if (Paths.get(entry.getName()).getFileName().toString().equalsIgnoreCase(GIT_PROPERTIES_FILE_NAME)) {
- Properties gitProperties = new Properties();
- gitProperties.load(in);
- result.add(new Pair<>(fullEntryName, gitProperties));
- } else if (isJarLikeFile(entry.getName()) && recursiveSearch) {
- result.addAll(findGitPropertiesInArchive(new JarInputStream(in), fullEntryName, true));
- }
- }
- if (isEmpty) {
- throw new IOException(
- "No entries in Jar file " + archiveName + ". Is this a valid jar file?. If so, please report to CQSE.");
- }
- return result;
- }
-
- /**
- * Returns the CommitInfo (revision and branch + timestmap) from a git properties file. The revision can be either
- * in {@link #GIT_PROPERTIES_GIT_COMMIT_ID} or {@link #GIT_PROPERTIES_GIT_COMMIT_ID_FULL}. The branch and timestamp
- * in {@link #GIT_PROPERTIES_GIT_BRANCH} + {@link #GIT_PROPERTIES_GIT_COMMIT_TIME} or in
- * {@link #GIT_PROPERTIES_TEAMSCALE_COMMIT_BRANCH} + {@link #GIT_PROPERTIES_TEAMSCALE_COMMIT_TIME}. By default,
- * times will be parsed with {@link #GIT_PROPERTIES_DEFAULT_GRADLE_DATE_FORMAT} and
- * {@link #GIT_PROPERTIES_DEFAULT_MAVEN_DATE_FORMAT}. An additional format can be given with
- * {@code dateTimeFormatter}
- */
- public static CommitInfo getCommitInfoFromGitProperties(
- Properties gitProperties, String entryName, File jarFile,
- @Nullable DateTimeFormatter additionalDateTimeFormatter) throws InvalidGitPropertiesException {
-
- DateTimeFormatter dateTimeFormatter = createDateTimeFormatter(additionalDateTimeFormatter);
-
- // Get Revision
- String revision = getRevisionFromGitProperties(gitProperties);
-
- // Get branch and timestamp from git.commit.branch and git.commit.id
- CommitDescriptor commitDescriptor = getCommitDescriptorFromDefaultGitPropertyValues(gitProperties, entryName,
- jarFile, dateTimeFormatter);
- // When read from these properties, we should prefer to upload to the revision
- boolean preferCommitDescriptorOverRevision = false;
-
-
- // Get branch and timestamp from teamscale.commit.branch and teamscale.commit.time (TS-38561)
- CommitDescriptor teamscaleTimestampBasedCommitDescriptor = getCommitDescriptorFromTeamscaleTimestampProperty(
- gitProperties, entryName, jarFile, dateTimeFormatter);
- if (teamscaleTimestampBasedCommitDescriptor != null) {
- // In this case, as we specifically set this property, we should prefer branch and timestamp to the revision
- preferCommitDescriptorOverRevision = true;
- commitDescriptor = teamscaleTimestampBasedCommitDescriptor;
- }
-
- if (StringUtils.isEmpty(revision) && commitDescriptor == null) {
- throw new InvalidGitPropertiesException(
- "No entry or invalid value for '" + GIT_PROPERTIES_GIT_COMMIT_ID + "', '" + GIT_PROPERTIES_GIT_COMMIT_ID_FULL +
- "', '" + GIT_PROPERTIES_TEAMSCALE_COMMIT_BRANCH + "' and " + GIT_PROPERTIES_TEAMSCALE_COMMIT_TIME + "'\n" +
- "Location: Entry '" + entryName + "' in jar file '" + jarFile + "'." +
- "\nContents of " + GIT_PROPERTIES_FILE_NAME + ":\n" + gitProperties);
- }
-
- CommitInfo commitInfo = new CommitInfo(revision, commitDescriptor);
- commitInfo.preferCommitDescriptorOverRevision = preferCommitDescriptorOverRevision;
- return commitInfo;
- }
-
- private static @NotNull DateTimeFormatter createDateTimeFormatter(
- @org.jetbrains.annotations.Nullable DateTimeFormatter additionalDateTimeFormatter) {
- DateTimeFormatter defaultDateTimeFormatter = DateTimeFormatter.ofPattern(
- String.format("[%s][%s]", GIT_PROPERTIES_DEFAULT_MAVEN_DATE_FORMAT,
- GIT_PROPERTIES_DEFAULT_GRADLE_DATE_FORMAT));
- DateTimeFormatterBuilder builder = new DateTimeFormatterBuilder().append(defaultDateTimeFormatter);
- if (additionalDateTimeFormatter != null) {
- builder.append(additionalDateTimeFormatter);
- }
- return builder.toFormatter();
- }
-
- private static String getRevisionFromGitProperties(Properties gitProperties) {
- String revision = gitProperties.getProperty(GIT_PROPERTIES_GIT_COMMIT_ID);
- if (StringUtils.isEmpty(revision)) {
- revision = gitProperties.getProperty(GIT_PROPERTIES_GIT_COMMIT_ID_FULL);
- }
- return revision;
- }
-
- private static CommitDescriptor getCommitDescriptorFromTeamscaleTimestampProperty(Properties gitProperties,
- String entryName, File jarFile,
- DateTimeFormatter dateTimeFormatter) throws InvalidGitPropertiesException {
- String teamscaleCommitBranch = gitProperties.getProperty(GIT_PROPERTIES_TEAMSCALE_COMMIT_BRANCH);
- String teamscaleCommitTime = gitProperties.getProperty(GIT_PROPERTIES_TEAMSCALE_COMMIT_TIME);
-
- if (StringUtils.isEmpty(teamscaleCommitBranch) || StringUtils.isEmpty(teamscaleCommitTime)) {
- return null;
- }
-
- String teamscaleTimestampRegex = "\\d*(?:p\\d*)?";
- Matcher teamscaleTimestampMatcher = Pattern.compile(teamscaleTimestampRegex).matcher(teamscaleCommitTime);
- if (teamscaleTimestampMatcher.matches()) {
- return new CommitDescriptor(teamscaleCommitBranch, teamscaleCommitTime);
- }
-
- long epochTimestamp;
- try {
- epochTimestamp = ZonedDateTime.parse(teamscaleCommitTime, dateTimeFormatter).toInstant().toEpochMilli();
- } catch (DateTimeParseException e) {
- throw new InvalidGitPropertiesException(
- "Cannot parse commit time '" + teamscaleCommitTime + "' in the '" + GIT_PROPERTIES_TEAMSCALE_COMMIT_TIME +
- "' property. It needs to be in the date formats '" + GIT_PROPERTIES_DEFAULT_MAVEN_DATE_FORMAT +
- "' or '" + GIT_PROPERTIES_DEFAULT_GRADLE_DATE_FORMAT + "' or match the Teamscale timestamp format '"
- + teamscaleTimestampRegex + "'." +
- "\nLocation: Entry '" + entryName + "' in jar file '" + jarFile + "'." +
- "\nContents of " + GIT_PROPERTIES_FILE_NAME + ":\n" + gitProperties, e);
- }
-
- return new CommitDescriptor(teamscaleCommitBranch, epochTimestamp);
- }
-
- private static CommitDescriptor getCommitDescriptorFromDefaultGitPropertyValues(Properties gitProperties,
- String entryName, File jarFile,
- DateTimeFormatter dateTimeFormatter) throws InvalidGitPropertiesException {
- String gitBranch = gitProperties.getProperty(GIT_PROPERTIES_GIT_BRANCH);
- String gitTime = gitProperties.getProperty(GIT_PROPERTIES_GIT_COMMIT_TIME);
- if (!StringUtils.isEmpty(gitBranch) && !StringUtils.isEmpty(gitTime)) {
- long gitTimestamp;
- try {
- gitTimestamp = ZonedDateTime.parse(gitTime, dateTimeFormatter).toInstant().toEpochMilli();
- } catch (DateTimeParseException e) {
- throw new InvalidGitPropertiesException(
- "Could not parse the timestamp in property '" + GIT_PROPERTIES_GIT_COMMIT_TIME + "'." +
- "\nLocation: Entry '" + entryName + "' in jar file '" + jarFile + "'." +
- "\nContents of " + GIT_PROPERTIES_FILE_NAME + ":\n" + gitProperties, e);
- }
- return new CommitDescriptor(gitBranch, gitTimestamp);
- }
- return null;
- }
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitSingleProjectPropertiesLocator.java b/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitSingleProjectPropertiesLocator.java
deleted file mode 100644
index e9b654670..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitSingleProjectPropertiesLocator.java
+++ /dev/null
@@ -1,115 +0,0 @@
-package com.teamscale.jacoco.agent.commit_resolution.git_properties;
-
-import com.teamscale.jacoco.agent.logging.LoggingUtils;
-import com.teamscale.jacoco.agent.upload.delay.DelayedUploader;
-import com.teamscale.jacoco.agent.util.DaemonThreadFactory;
-import org.jetbrains.annotations.Nullable;
-import org.slf4j.Logger;
-
-import java.io.File;
-import java.io.IOException;
-import java.time.format.DateTimeFormatter;
-import java.util.List;
-import java.util.concurrent.Executor;
-import java.util.concurrent.Executors;
-
-/**
- * Searches a Jar/War/Ear/... file for a git.properties file in order to enable upload for the commit described therein,
- * e.g. to Teamscale, via a {@link DelayedUploader}.
- */
-public class GitSingleProjectPropertiesLocator implements IGitPropertiesLocator {
-
- private final Logger logger = LoggingUtils.getLogger(this);
- private final Executor executor;
- private T foundData = null;
- private File jarFileWithGitProperties = null;
-
- private final DelayedUploader uploader;
- private final DataExtractor dataExtractor;
-
- private final boolean recursiveSearch;
- private final @Nullable DateTimeFormatter gitPropertiesCommitTimeFormat;
-
- public GitSingleProjectPropertiesLocator(DelayedUploader uploader, DataExtractor dataExtractor,
- boolean recursiveSearch,
- @Nullable DateTimeFormatter gitPropertiesCommitTimeFormat) {
- // using a single threaded executor allows this class to be lock-free
- this(uploader, dataExtractor, Executors
- .newSingleThreadExecutor(
- new DaemonThreadFactory(GitSingleProjectPropertiesLocator.class,
- "git.properties Jar scanner thread")),
- recursiveSearch, gitPropertiesCommitTimeFormat);
- }
-
- /**
- * Visible for testing. Allows tests to control the {@link Executor} in order to test the asynchronous functionality
- * of this class.
- */
- public GitSingleProjectPropertiesLocator(DelayedUploader uploader, DataExtractor dataExtractor,
- Executor executor,
- boolean recursiveSearch,
- @Nullable DateTimeFormatter gitPropertiesCommitTimeFormat) {
- this.uploader = uploader;
- this.dataExtractor = dataExtractor;
- this.executor = executor;
- this.recursiveSearch = recursiveSearch;
- this.gitPropertiesCommitTimeFormat = gitPropertiesCommitTimeFormat;
- }
-
- /**
- * Asynchronously searches the given jar file for a git.properties file.
- */
- @Override
- public void searchFileForGitPropertiesAsync(File file, boolean isJarFile) {
- executor.execute(() -> searchFile(file, isJarFile));
- }
-
- private void searchFile(File file, boolean isJarFile) {
- logger.debug("Searching jar file {} for a single git.properties", file);
- try {
- List data = dataExtractor.extractData(file, isJarFile, recursiveSearch, gitPropertiesCommitTimeFormat);
- if (data.isEmpty()) {
- logger.debug("No git.properties files found in {}", file.toString());
- return;
- }
- if (data.size() > 1) {
- logger.warn("Multiple git.properties files found in {}", file.toString() +
- ". Using the first one: " + data.get(0));
-
- }
- T dataEntry = data.get(0);
-
- if (foundData != null) {
- if (!foundData.equals(dataEntry)) {
- logger.warn(
- "Found inconsistent git.properties files: {} contained data {} while {} contained {}." +
- " Please ensure that all git.properties files of your application are consistent." +
- " Otherwise, you may" +
- " be uploading to the wrong project/commit which will result in incorrect coverage data" +
- " displayed in Teamscale. If you cannot fix the inconsistency, you can manually" +
- " specify a Jar/War/Ear/... file from which to read the correct git.properties" +
- " file with the agent's teamscale-git-properties-jar parameter.",
- jarFileWithGitProperties, foundData, file, data);
- }
- return;
- }
-
- logger.debug("Found git.properties file in {} and found commit descriptor {}", file.toString(),
- dataEntry);
- foundData = dataEntry;
- jarFileWithGitProperties = file;
- uploader.setCommitAndTriggerAsynchronousUpload(dataEntry);
- } catch (IOException | InvalidGitPropertiesException e) {
- logger.error("Error during asynchronous search for git.properties in {}", file.toString(), e);
- }
- }
-
- /** Functional interface for data extraction from a jar file. */
- @FunctionalInterface
- public interface DataExtractor {
- /** Extracts data from the JAR. */
- List extractData(File file, boolean isJarFile,
- boolean recursiveSearch,
- @Nullable DateTimeFormatter gitPropertiesCommitTimeFormat) throws IOException, InvalidGitPropertiesException;
- }
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/InvalidGitPropertiesException.java b/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/InvalidGitPropertiesException.java
deleted file mode 100644
index d2491d43c..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/InvalidGitPropertiesException.java
+++ /dev/null
@@ -1,14 +0,0 @@
-package com.teamscale.jacoco.agent.commit_resolution.git_properties;
-
-/**
- * Thrown in case a git.properties file is found but it is malformed.
- */
-public class InvalidGitPropertiesException extends Exception {
- /*package*/ InvalidGitPropertiesException(String s, Throwable throwable) {
- super(s, throwable);
- }
-
- public InvalidGitPropertiesException(String s) {
- super(s);
- }
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/sapnwdi/NwdiMarkerClassLocatingTransformer.java b/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/sapnwdi/NwdiMarkerClassLocatingTransformer.java
deleted file mode 100644
index 87d04b6f8..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/sapnwdi/NwdiMarkerClassLocatingTransformer.java
+++ /dev/null
@@ -1,92 +0,0 @@
-package com.teamscale.jacoco.agent.commit_resolution.sapnwdi;
-
-import com.teamscale.client.CommitDescriptor;
-import com.teamscale.client.StringUtils;
-import com.teamscale.jacoco.agent.logging.LoggingUtils;
-import com.teamscale.jacoco.agent.options.sapnwdi.DelayedSapNwdiMultiUploader;
-import com.teamscale.jacoco.agent.options.sapnwdi.SapNwdiApplication;
-import com.teamscale.report.util.ClasspathWildcardIncludeFilter;
-import org.slf4j.Logger;
-
-import java.lang.instrument.ClassFileTransformer;
-import java.net.URL;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.nio.file.Paths;
-import java.nio.file.attribute.BasicFileAttributes;
-import java.security.CodeSource;
-import java.security.ProtectionDomain;
-import java.util.Collection;
-import java.util.Map;
-import java.util.stream.Collectors;
-
-/**
- * {@link ClassFileTransformer} that doesn't change the loaded classes but guesses the rough commit timestamp by
- * inspecting the last modification date of the applications marker class file.
- */
-public class NwdiMarkerClassLocatingTransformer implements ClassFileTransformer {
-
- /** The Design time repository-git-bridge (DTR-bridge) currently only exports a single branch named master. */
- private static final String DTR_BRIDGE_DEFAULT_BRANCH = "master";
- private final Logger logger = LoggingUtils.getLogger(this);
- private final DelayedSapNwdiMultiUploader store;
- private final ClasspathWildcardIncludeFilter locationIncludeFilter;
- private final Map markerClassesToApplications;
-
- public NwdiMarkerClassLocatingTransformer(
- DelayedSapNwdiMultiUploader store,
- ClasspathWildcardIncludeFilter locationIncludeFilter,
- Collection apps) {
- this.store = store;
- this.locationIncludeFilter = locationIncludeFilter;
- this.markerClassesToApplications = apps.stream().collect(
- Collectors.toMap(sapNwdiApplication -> sapNwdiApplication.getMarkerClass().replace('.', '/'),
- application -> application));
- }
-
- @Override
- public byte[] transform(ClassLoader classLoader, String className, Class> aClass,
- ProtectionDomain protectionDomain, byte[] classFileContent) {
- if (protectionDomain == null) {
- // happens for e.g. java.lang. We can ignore these classes
- return null;
- }
-
- if (StringUtils.isEmpty(className) || !locationIncludeFilter.isIncluded(className)) {
- // only search in jar files of included classes
- return null;
- }
-
- if (!this.markerClassesToApplications.containsKey(className)) {
- // only kick off search if the marker class was found.
- return null;
- }
-
- try {
- CodeSource codeSource = protectionDomain.getCodeSource();
- if (codeSource == null) {
- // unknown when this can happen, we suspect when code is generated at runtime
- // but there's nothing else we can do here in either case
- return null;
- }
-
- URL jarOrClassFolderUrl = codeSource.getLocation();
- logger.debug("Found " + className + " in " + jarOrClassFolderUrl);
-
- if (jarOrClassFolderUrl.getProtocol().equalsIgnoreCase("file")) {
- Path file = Paths.get(jarOrClassFolderUrl.toURI());
- BasicFileAttributes attr = Files.readAttributes(file, BasicFileAttributes.class);
- SapNwdiApplication application = markerClassesToApplications.get(className);
- CommitDescriptor commitDescriptor = new CommitDescriptor(
- DTR_BRIDGE_DEFAULT_BRANCH, attr.lastModifiedTime().toMillis());
- store.setCommitForApplication(commitDescriptor, application);
- }
- } catch (Throwable e) {
- // we catch Throwable to be sure that we log all errors as anything thrown from this method is
- // silently discarded by the JVM
- logger.error("Failed to process class {} trying to determine its last modification timestamp.", className,
- e);
- }
- return null;
- }
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/configuration/AgentOptionReceiveException.java b/agent/src/main/java/com/teamscale/jacoco/agent/configuration/AgentOptionReceiveException.java
deleted file mode 100644
index 80f9bb19d..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/configuration/AgentOptionReceiveException.java
+++ /dev/null
@@ -1,25 +0,0 @@
-package com.teamscale.jacoco.agent.configuration;
-
-/** Thrown when retrieving the profiler configuration from Teamscale fails. */
-public class AgentOptionReceiveException extends Exception {
-
- /**
- * Serialization ID.
- */
- private static final long serialVersionUID = 1L;
-
- /**
- * Constructor.
- */
- public AgentOptionReceiveException(String message) {
- super(message);
- }
-
- /**
- * Constructor.
- */
- public AgentOptionReceiveException(String message, Throwable cause) {
- super(message, cause);
- }
-
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/configuration/ConfigurationViaTeamscale.java b/agent/src/main/java/com/teamscale/jacoco/agent/configuration/ConfigurationViaTeamscale.java
deleted file mode 100644
index d4e59a7e6..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/configuration/ConfigurationViaTeamscale.java
+++ /dev/null
@@ -1,168 +0,0 @@
-package com.teamscale.jacoco.agent.configuration;
-
-import com.fasterxml.jackson.core.JsonProcessingException;
-import com.teamscale.client.ITeamscaleService;
-import com.teamscale.client.JsonUtils;
-import com.teamscale.client.ProcessInformation;
-import com.teamscale.client.ProfilerConfiguration;
-import com.teamscale.client.ProfilerInfo;
-import com.teamscale.client.ProfilerRegistration;
-import com.teamscale.client.TeamscaleServiceGenerator;
-import com.teamscale.jacoco.agent.logging.LoggingUtils;
-import com.teamscale.jacoco.agent.util.AgentUtils;
-import com.teamscale.report.util.ILogger;
-import okhttp3.HttpUrl;
-import okhttp3.ResponseBody;
-import org.jetbrains.annotations.NotNull;
-import retrofit2.Response;
-
-import java.io.IOException;
-import java.time.Duration;
-import java.util.concurrent.Executors;
-import java.util.concurrent.ScheduledExecutorService;
-import java.util.concurrent.TimeUnit;
-
-/**
- * Responsible for holding the configuration that was retrieved from Teamscale and sending regular heartbeat events to
- * keep the profiler information in Teamscale up to date.
- */
-public class ConfigurationViaTeamscale {
-
- /**
- * Two minute timeout. This is quite high to account for an eventual high load on the Teamscale server. This is a
- * tradeoff between fast application startup and potentially missing test coverage.
- */
- private static final Duration LONG_TIMEOUT = Duration.ofMinutes(2);
-
- /**
- * The UUID that Teamscale assigned to this instance of the profiler during the registration. This ID needs to be
- * used when communicating with Teamscale.
- */
- private final String profilerId;
-
- private final ITeamscaleService teamscaleClient;
- private final ProfilerInfo profilerInfo;
-
- public ConfigurationViaTeamscale(ITeamscaleService teamscaleClient, ProfilerRegistration profilerRegistration,
- ProcessInformation processInformation) {
- this.teamscaleClient = teamscaleClient;
- this.profilerId = profilerRegistration.profilerId;
- this.profilerInfo = new ProfilerInfo(processInformation, profilerRegistration.profilerConfiguration);
- }
-
- /**
- * Tries to retrieve the profiler configuration from Teamscale. In case retrieval fails the method throws a
- * {@link AgentOptionReceiveException}.
- */
- public static @NotNull ConfigurationViaTeamscale retrieve(ILogger logger, String configurationId, HttpUrl url,
- String userName,
- String userAccessToken) throws AgentOptionReceiveException {
- ITeamscaleService teamscaleClient = TeamscaleServiceGenerator
- .createService(ITeamscaleService.class, url, userName, userAccessToken, AgentUtils.USER_AGENT,
- LONG_TIMEOUT, LONG_TIMEOUT);
- try {
- ProcessInformation processInformation = new ProcessInformationRetriever(logger).getProcessInformation();
- Response response = teamscaleClient.registerProfiler(configurationId,
- processInformation).execute();
- if (!response.isSuccessful()) {
- throw new AgentOptionReceiveException(
- "Failed to retrieve profiler configuration from Teamscale due to failed request. Http status: " + response.code()
- + " Body: " + response.errorBody().string());
- }
-
- ResponseBody body = response.body();
- return parseProfilerRegistration(body, response, teamscaleClient, processInformation);
- } catch (IOException e) {
- // we include the causing error message in this exception's message since this causes it to be printed
- // to stderr which is much more helpful than just saying "something didn't work"
- throw new AgentOptionReceiveException(
- "Failed to retrieve profiler configuration from Teamscale due to network error: " + LoggingUtils.getStackTraceAsString(
- e),
- e);
- }
- }
-
- private static @NotNull ConfigurationViaTeamscale parseProfilerRegistration(ResponseBody body,
- Response response, ITeamscaleService teamscaleClient,
- ProcessInformation processInformation) throws AgentOptionReceiveException, IOException {
- if (body == null) {
- throw new AgentOptionReceiveException(
- "Failed to retrieve profiler configuration from Teamscale due to empty response. HTTP code: " + response.code());
- }
- // We may only call this once
- String bodyString = body.string();
- try {
- ProfilerRegistration registration = JsonUtils.deserialize(bodyString,
- ProfilerRegistration.class);
- if (registration == null) {
- throw new AgentOptionReceiveException(
- "Failed to retrieve profiler configuration from Teamscale due to invalid JSON. HTTP code: " + response.code() + " Response: " + bodyString);
- }
- return new ConfigurationViaTeamscale(teamscaleClient, registration, processInformation);
- } catch (JsonProcessingException e) {
- throw new AgentOptionReceiveException(
- "Failed to retrieve profiler configuration from Teamscale due to invalid JSON. HTTP code: " + response.code() + " Response: " + bodyString,
- e);
- }
- }
-
- /** Returns the profiler configuration that was retrieved from Teamscale. */
- public ProfilerConfiguration getProfilerConfiguration() {
- return profilerInfo.profilerConfiguration;
- }
-
-
- /**
- * Starts a heartbeat thread and registers a shutdown hook.
- *
- * This spawns a new thread every minute which sends a heartbeat to Teamscale. It also registers a shutdown hook
- * that unregisters the profiler from Teamscale.
- */
- public void startHeartbeatThreadAndRegisterShutdownHook() {
- ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(runnable -> {
- Thread thread = new Thread(runnable);
- thread.setDaemon(true);
- return thread;
- });
-
- executor.scheduleAtFixedRate(this::sendHeartbeat, 1, 1, TimeUnit.MINUTES);
-
- Runtime.getRuntime().addShutdownHook(new Thread(() -> {
- executor.shutdownNow();
- unregisterProfiler();
- }));
- }
-
- private void sendHeartbeat() {
- try {
- Response response = teamscaleClient.sendHeartbeat(profilerId, profilerInfo).execute();
- if (!response.isSuccessful()) {
- LoggingUtils.getLogger(this)
- .error("Failed to send heartbeat. Teamscale responded with: " + response.errorBody().string());
- }
- } catch (IOException e) {
- LoggingUtils.getLogger(this).error("Failed to send heartbeat to Teamscale!", e);
- }
- }
-
- /** Unregisters the profiler in Teamscale (marks it as shut down). */
- public void unregisterProfiler() {
- try {
- Response response = teamscaleClient.unregisterProfiler(profilerId).execute();
- if (response.code() == 405) {
- response = teamscaleClient.unregisterProfilerLegacy(profilerId).execute();
- }
- if (!response.isSuccessful()) {
- LoggingUtils.getLogger(this)
- .error("Failed to unregister profiler. Teamscale responded with: " + response.errorBody()
- .string());
- }
- } catch (IOException e) {
- LoggingUtils.getLogger(this).error("Failed to unregister profiler!", e);
- }
- }
-
- public String getProfilerId() {
- return profilerId;
- }
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/configuration/ProcessInformationRetriever.java b/agent/src/main/java/com/teamscale/jacoco/agent/configuration/ProcessInformationRetriever.java
deleted file mode 100644
index 63a34f4cf..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/configuration/ProcessInformationRetriever.java
+++ /dev/null
@@ -1,64 +0,0 @@
-package com.teamscale.jacoco.agent.configuration;
-
-import com.teamscale.client.ProcessInformation;
-import com.teamscale.report.util.ILogger;
-
-import java.lang.management.ManagementFactory;
-import java.lang.reflect.InvocationTargetException;
-import java.net.InetAddress;
-import java.net.UnknownHostException;
-
-/**
- * Is responsible for retrieving process information such as the host name and process ID.
- */
-public class ProcessInformationRetriever {
-
- private final ILogger logger;
-
- public ProcessInformationRetriever(ILogger logger) {
- this.logger = logger;
- }
-
- /**
- * Retrieves the process information, including the host name and process ID.
- */
- public ProcessInformation getProcessInformation() {
- String hostName = getHostName();
- String processId = getPID();
- return new ProcessInformation(hostName, processId, System.currentTimeMillis());
- }
-
- /**
- * Retrieves the host name of the local machine.
- */
- private String getHostName() {
- try {
- InetAddress inetAddress = InetAddress.getLocalHost();
- return inetAddress.getHostName();
- } catch (UnknownHostException e) {
- logger.error("Failed to determine hostname!", e);
- return "";
- }
- }
-
- /**
- * Returns a string that probably contains the PID.
- *
- * On Java 9 there is an API to get the PID. But since we support Java 8, we may fall back to an undocumented API
- * that at least contains the PID in most JVMs.
- *
- * See This
- * StackOverflow question
- */
- public static String getPID() {
- try {
- Class> processHandleClass = Class.forName("java.lang.ProcessHandle");
- Object processHandle = processHandleClass.getMethod("current").invoke(null);
- Long pid = (Long) processHandleClass.getMethod("pid").invoke(processHandle);
- return pid.toString();
- } catch (ClassNotFoundException | NoSuchMethodException | IllegalAccessException |
- InvocationTargetException e) {
- return ManagementFactory.getRuntimeMXBean().getName();
- }
- }
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/convert/ConvertCommand.java b/agent/src/main/java/com/teamscale/jacoco/agent/convert/ConvertCommand.java
deleted file mode 100644
index 116c5fe44..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/convert/ConvertCommand.java
+++ /dev/null
@@ -1,171 +0,0 @@
-/*-------------------------------------------------------------------------+
-| |
-| Copyright (c) 2009-2017 CQSE GmbH |
-| |
-+-------------------------------------------------------------------------*/
-package com.teamscale.jacoco.agent.convert;
-
-import com.beust.jcommander.JCommander;
-import com.beust.jcommander.Parameter;
-import com.beust.jcommander.Parameters;
-import com.teamscale.client.FileSystemUtils;
-import com.teamscale.client.StringUtils;
-import com.teamscale.jacoco.agent.commandline.ICommand;
-import com.teamscale.jacoco.agent.commandline.Validator;
-import com.teamscale.jacoco.agent.options.ClasspathUtils;
-import com.teamscale.jacoco.agent.options.FilePatternResolver;
-import com.teamscale.jacoco.agent.util.Assertions;
-import com.teamscale.report.EDuplicateClassFileBehavior;
-import com.teamscale.report.util.CommandLineLogger;
-
-import java.io.File;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.stream.Collectors;
-
-/**
- * Encapsulates all command line options for the convert command for parsing with {@link JCommander}.
- */
-@Parameters(commandNames = "convert", commandDescription = "Converts a binary .exec coverage file to XML. " +
- "Note that the XML report will only contain source file coverage information, but no class coverage.")
-public class ConvertCommand implements ICommand {
-
- /** The directories and/or zips that contain all class files being profiled. */
- @Parameter(names = {"--class-dir", "--jar", "-c"}, required = true, description = ""
- + "The directories or zip/ear/jar/war/... files that contain the compiled Java classes being profiled."
- + " Searches recursively, including inside zips. You may also supply a *.txt file with one path per line.")
- /* package */ List classDirectoriesOrZips = new ArrayList<>();
-
- /**
- * Wildcard include patterns to apply during JaCoCo's traversal of class files.
- */
- @Parameter(names = {"--includes"}, description = ""
- + "Wildcard include patterns to apply to all found class file locations during JaCoCo's traversal of class files."
- + " Note that zip contents are separated from zip files with @ and that you can filter only"
- + " class files, not intermediate folders/zips. Use with great care as missing class files"
- + " lead to broken coverage files! Turn on debug logging to see which locations are being filtered."
- + " Defaults to no filtering. Excludes overrule includes.")
- /* package */ List locationIncludeFilters = new ArrayList<>();
-
- /**
- * Wildcard exclude patterns to apply during JaCoCo's traversal of class files.
- */
- @Parameter(names = {"--excludes", "-e"}, description = ""
- + "Wildcard exclude patterns to apply to all found class file locations during JaCoCo's traversal of class files."
- + " Note that zip contents are separated from zip files with @ and that you can filter only"
- + " class files, not intermediate folders/zips. Use with great care as missing class files"
- + " lead to broken coverage files! Turn on debug logging to see which locations are being filtered."
- + " Defaults to no filtering. Excludes overrule includes.")
- /* package */ List locationExcludeFilters = new ArrayList<>();
-
- /** The directory to write the XML traces to. */
- @Parameter(names = {"--in", "-i"}, required = true, description = "" + "The binary .exec file(s), test details and " +
- "test executions to read. Can be a single file or a directory that is recursively scanned for relevant files.")
- /* package */ List inputFiles = new ArrayList<>();
-
- /** The directory to write the XML traces to. */
- @Parameter(names = {"--out", "-o"}, required = true, description = ""
- + "The file to write the generated XML report to.")
- /* package */ String outputFile = "";
-
- /** Whether to ignore duplicate, non-identical class files. */
- @Parameter(names = {"--duplicates", "-d"}, arity = 1, description = ""
- + "Whether to ignore duplicate, non-identical class files."
- + " This is discouraged and may result in incorrect coverage files. Defaults to WARN. " +
- "Options are FAIL, WARN and IGNORE.")
- /* package */ EDuplicateClassFileBehavior duplicateClassFileBehavior = EDuplicateClassFileBehavior.WARN;
-
- /** Whether to ignore uncovered class files. */
- @Parameter(names = {"--ignore-uncovered-classes"}, required = false, arity = 1, description = ""
- + "Whether to ignore uncovered classes."
- + " These classes will not be part of the XML report at all, making it considerably smaller in some cases. Defaults to false.")
- /* package */ boolean shouldIgnoreUncoveredClasses = false;
-
- /** Whether testwise coverage or jacoco coverage should be generated. */
- @Parameter(names = {"--testwise-coverage", "-t"}, required = false, arity = 0, description = "Whether testwise " +
- "coverage or jacoco coverage should be generated.")
- /* package */ boolean shouldGenerateTestwiseCoverage = false;
-
- /** After how many tests testwise coverage should be split into multiple reports. */
- @Parameter(names = {"--split-after", "-s"}, required = false, arity = 1, description = "After how many tests " +
- "testwise coverage should be split into multiple reports (Default is 5000).")
- private int splitAfter = 5000;
-
- /** @see #classDirectoriesOrZips */
- public List getClassDirectoriesOrZips() throws IOException {
- return ClasspathUtils
- .resolveClasspathTextFiles("class-dir", new FilePatternResolver(new CommandLineLogger()),
- classDirectoriesOrZips);
- }
-
- /** @see #locationIncludeFilters */
- public List getLocationIncludeFilters() {
- return locationIncludeFilters;
- }
-
- /** @see #locationExcludeFilters */
- public List getLocationExcludeFilters() {
- return locationExcludeFilters;
- }
-
- /** @see #inputFiles */
- public List getInputFiles() {
- return inputFiles.stream().map(File::new).collect(Collectors.toList());
- }
-
- /** @see #outputFile */
- public File getOutputFile() {
- return new File(outputFile);
- }
-
- /** @see #splitAfter */
- public int getSplitAfter() {
- return splitAfter;
- }
-
- /** @see #duplicateClassFileBehavior */
- public EDuplicateClassFileBehavior getDuplicateClassFileBehavior() {
- return duplicateClassFileBehavior;
- }
-
- /** Makes sure the arguments are valid. */
- @Override
- public Validator validate() {
- Validator validator = new Validator();
-
- List classDirectoriesOrZips = new ArrayList<>();
- validator.ensure(() -> classDirectoriesOrZips.addAll(getClassDirectoriesOrZips()));
- validator.isFalse(classDirectoriesOrZips.isEmpty(),
- "You must specify at least one directory or zip that contains class files");
- for (File path : classDirectoriesOrZips) {
- validator.isTrue(path.exists(), "Path '" + path + "' does not exist");
- validator.isTrue(path.canRead(), "Path '" + path + "' is not readable");
- }
-
- for (File inputFile : getInputFiles()) {
- validator.isTrue(inputFile.exists() && inputFile.canRead(),
- "Cannot read the input file " + inputFile);
- }
-
- validator.ensure(() -> {
- Assertions.isFalse(StringUtils.isEmpty(outputFile), "You must specify an output file");
- File outputDir = getOutputFile().getAbsoluteFile().getParentFile();
- FileSystemUtils.ensureDirectoryExists(outputDir);
- Assertions.isTrue(outputDir.canWrite(), "Path '" + outputDir + "' is not writable");
- });
-
- return validator;
- }
-
- /** {@inheritDoc} */
- @Override
- public void run() throws Exception {
- Converter converter = new Converter(this);
- if (this.shouldGenerateTestwiseCoverage) {
- converter.runTestwiseCoverageReportGeneration();
- } else {
- converter.runJaCoCoReportGeneration();
- }
- }
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/convert/Converter.java b/agent/src/main/java/com/teamscale/jacoco/agent/convert/Converter.java
deleted file mode 100644
index e46cc5852..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/convert/Converter.java
+++ /dev/null
@@ -1,95 +0,0 @@
-package com.teamscale.jacoco.agent.convert;
-
-import com.teamscale.client.TestDetails;
-import com.teamscale.jacoco.agent.logging.LoggingUtils;
-import com.teamscale.jacoco.agent.options.AgentOptionParseException;
-import com.teamscale.jacoco.agent.util.Benchmark;
-import com.teamscale.report.ReportUtils;
-import com.teamscale.report.jacoco.EmptyReportException;
-import com.teamscale.report.jacoco.JaCoCoXmlReportGenerator;
-import com.teamscale.report.testwise.ETestArtifactFormat;
-import com.teamscale.report.testwise.TestwiseCoverageReportWriter;
-import com.teamscale.report.testwise.jacoco.JaCoCoTestwiseReportGenerator;
-import com.teamscale.report.testwise.model.TestExecution;
-import com.teamscale.report.testwise.model.factory.TestInfoFactory;
-import com.teamscale.report.util.ClasspathWildcardIncludeFilter;
-import com.teamscale.report.util.CommandLineLogger;
-import com.teamscale.report.util.ILogger;
-import org.slf4j.Logger;
-
-import java.io.File;
-import java.io.IOException;
-import java.nio.file.Paths;
-import java.util.List;
-
-import static com.teamscale.jacoco.agent.logging.LoggingUtils.wrap;
-
-/** Converts one .exec binary coverage file to XML. */
-public class Converter {
-
- /** The command line arguments. */
- private ConvertCommand arguments;
-
- /** Constructor. */
- public Converter(ConvertCommand arguments) {
- this.arguments = arguments;
- }
-
- /** Converts one .exec binary coverage file to XML. */
- public void runJaCoCoReportGeneration() throws IOException {
- List jacocoExecutionDataList = ReportUtils
- .listFiles(ETestArtifactFormat.JACOCO, arguments.getInputFiles());
-
- Logger logger = LoggingUtils.getLogger(this);
- JaCoCoXmlReportGenerator generator = new JaCoCoXmlReportGenerator(arguments.getClassDirectoriesOrZips(),
- getWildcardIncludeExcludeFilter(), arguments.getDuplicateClassFileBehavior(),
- arguments.shouldIgnoreUncoveredClasses,
- wrap(logger));
-
- try (Benchmark benchmark = new Benchmark("Generating the XML report")) {
- generator.convertExecFilesToReport(jacocoExecutionDataList, Paths.get(arguments.outputFile).toFile());
- } catch (EmptyReportException e) {
- logger.warn("Converted report was empty.", e);
- }
- }
-
- /** Converts one .exec binary coverage file, test details and test execution files to JSON testwise coverage. */
- public void runTestwiseCoverageReportGeneration() throws IOException, AgentOptionParseException {
- List testDetails = ReportUtils.readObjects(ETestArtifactFormat.TEST_LIST,
- TestDetails[].class, arguments.getInputFiles());
- List testExecutions = ReportUtils.readObjects(ETestArtifactFormat.TEST_EXECUTION,
- TestExecution[].class, arguments.getInputFiles());
-
- List jacocoExecutionDataList = ReportUtils
- .listFiles(ETestArtifactFormat.JACOCO, arguments.getInputFiles());
- ILogger logger = new CommandLineLogger();
-
- JaCoCoTestwiseReportGenerator generator = new JaCoCoTestwiseReportGenerator(
- arguments.getClassDirectoriesOrZips(),
- getWildcardIncludeExcludeFilter(),
- arguments.getDuplicateClassFileBehavior(),
- logger
- );
-
- TestInfoFactory testInfoFactory = new TestInfoFactory(testDetails, testExecutions);
-
- try (Benchmark benchmark = new Benchmark("Generating the testwise coverage report")) {
- logger.info(
- "Writing report with " + testDetails.size() + " Details/" + testExecutions.size() + " Results");
-
- try (TestwiseCoverageReportWriter coverageWriter = new TestwiseCoverageReportWriter(testInfoFactory,
- arguments.getOutputFile(), arguments.getSplitAfter(), null)) {
- for (File executionDataFile : jacocoExecutionDataList) {
- generator.convertAndConsume(executionDataFile, coverageWriter);
- }
- }
- }
- }
-
- private ClasspathWildcardIncludeFilter getWildcardIncludeExcludeFilter() {
- return new ClasspathWildcardIncludeFilter(
- String.join(":", arguments.getLocationIncludeFilters()),
- String.join(":", arguments.getLocationExcludeFilters()));
- }
-
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/logging/DebugLogDirectoryPropertyDefiner.java b/agent/src/main/java/com/teamscale/jacoco/agent/logging/DebugLogDirectoryPropertyDefiner.java
deleted file mode 100644
index ce83d7422..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/logging/DebugLogDirectoryPropertyDefiner.java
+++ /dev/null
@@ -1,18 +0,0 @@
-package com.teamscale.jacoco.agent.logging;
-
-import java.nio.file.Path;
-
-/** Defines a property that contains the path to which log files should be written. */
-public class DebugLogDirectoryPropertyDefiner extends LogDirectoryPropertyDefiner {
-
- /** File path for debug logging. */
- /* package */ static Path filePath = null;
-
- @Override
- public String getPropertyValue() {
- if (filePath == null) {
- return super.getPropertyValue();
- }
- return filePath.resolve("logs").toAbsolutePath().toString();
- }
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/logging/LogDirectoryPropertyDefiner.java b/agent/src/main/java/com/teamscale/jacoco/agent/logging/LogDirectoryPropertyDefiner.java
deleted file mode 100644
index a10c57221..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/logging/LogDirectoryPropertyDefiner.java
+++ /dev/null
@@ -1,15 +0,0 @@
-package com.teamscale.jacoco.agent.logging;
-
-import ch.qos.logback.core.PropertyDefinerBase;
-import com.teamscale.jacoco.agent.util.AgentUtils;
-
-import java.nio.file.Path;
-
-/** Defines a property that contains the default path to which log files should be written. */
-public class LogDirectoryPropertyDefiner extends PropertyDefinerBase {
- @Override
- public String getPropertyValue() {
- Path tempDirectory = AgentUtils.getMainTempDirectory();
- return tempDirectory.resolve("logs").toAbsolutePath().toString();
- }
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/logging/LogToTeamscaleAppender.java b/agent/src/main/java/com/teamscale/jacoco/agent/logging/LogToTeamscaleAppender.java
deleted file mode 100644
index 4a1906388..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/logging/LogToTeamscaleAppender.java
+++ /dev/null
@@ -1,207 +0,0 @@
-package com.teamscale.jacoco.agent.logging;
-
-import ch.qos.logback.classic.Logger;
-import ch.qos.logback.classic.LoggerContext;
-import ch.qos.logback.classic.spi.ILoggingEvent;
-import ch.qos.logback.core.AppenderBase;
-import ch.qos.logback.core.status.ErrorStatus;
-import com.teamscale.client.ITeamscaleService;
-import com.teamscale.client.ProfilerLogEntry;
-import com.teamscale.client.TeamscaleClient;
-import com.teamscale.jacoco.agent.options.AgentOptions;
-import org.jetbrains.annotations.Nullable;
-import retrofit2.Call;
-
-import java.net.ConnectException;
-import java.time.Duration;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.IdentityHashMap;
-import java.util.LinkedHashSet;
-import java.util.List;
-import java.util.Set;
-import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.Executors;
-import java.util.concurrent.ScheduledExecutorService;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.atomic.AtomicBoolean;
-
-import static com.teamscale.jacoco.agent.logging.LoggingUtils.getStackTraceFromEvent;
-
-/**
- * Custom log appender that sends logs to Teamscale; it buffers log that were not sent due to connection issues and
- * sends them later.
- */
-public class LogToTeamscaleAppender extends AppenderBase {
-
- /** Flush the logs after N elements are in the queue */
- private static final int BATCH_SIZE = 50;
-
- /** Flush the logs in the given time interval */
- private static final Duration FLUSH_INTERVAL = Duration.ofSeconds(3);
-
- /** The unique ID of the profiler */
- private String profilerId;
-
- /** The service client for sending logs to Teamscale */
- private static ITeamscaleService teamscaleClient;
-
- /**
- * Buffer for unsent logs. We use a set here to allow for removing entries fast after sending them to Teamscale was
- * successful.
- */
- private final LinkedHashSet logBuffer = new LinkedHashSet<>();
-
- /** Scheduler for sending logs after the configured time interval */
- private final ScheduledExecutorService scheduler;
-
- /** Active log flushing threads */
- private final Set> activeLogFlushes = Collections.newSetFromMap(new IdentityHashMap<>());
-
- /** Is there a flush going on right now? */
- private final AtomicBoolean isFlusing = new AtomicBoolean(false);
-
- public LogToTeamscaleAppender() {
- this.scheduler = Executors.newScheduledThreadPool(1, r -> {
- // Make the thread a daemon so that it does not prevent the JVM from terminating.
- Thread t = Executors.defaultThreadFactory().newThread(r);
- t.setDaemon(true);
- return t;
- });
- }
-
- @Override
- public void start() {
- super.start();
- scheduler.scheduleAtFixedRate(() -> {
- synchronized (activeLogFlushes) {
- activeLogFlushes.removeIf(CompletableFuture::isDone);
- if (this.activeLogFlushes.isEmpty()) {
- flush();
- }
- }
- }, FLUSH_INTERVAL.toMillis(), FLUSH_INTERVAL.toMillis(), TimeUnit.MILLISECONDS);
- }
-
- @Override
- protected void append(ILoggingEvent eventObject) {
- synchronized (logBuffer) {
- logBuffer.add(formatLog(eventObject));
- if (logBuffer.size() >= BATCH_SIZE) {
- flush();
- }
- }
- }
-
- private ProfilerLogEntry formatLog(ILoggingEvent eventObject) {
- String trace = getStackTraceFromEvent(eventObject);
- long timestamp = eventObject.getTimeStamp();
- String message = eventObject.getFormattedMessage();
- String severity = eventObject.getLevel().toString();
- return new ProfilerLogEntry(timestamp, message, trace, severity);
- }
-
- private void flush() {
- sendLogs();
- }
-
- /** Send logs in a separate thread */
- private void sendLogs() {
- synchronized (activeLogFlushes) {
- activeLogFlushes.add(CompletableFuture.runAsync(() -> {
- if (isFlusing.compareAndSet(false, true)) {
- try {
- if (teamscaleClient == null) {
- // There might be no connection configured.
- return;
- }
-
- List logsToSend;
- synchronized (logBuffer) {
- logsToSend = new ArrayList<>(logBuffer);
- }
-
- Call call = teamscaleClient.postProfilerLog(profilerId, logsToSend);
- retrofit2.Response response = call.execute();
- if (!response.isSuccessful()) {
- throw new IllegalStateException("Failed to send log: HTTP error code : " + response.code());
- }
-
- synchronized (logBuffer) {
- // Removing the logs that have been sent after the fact.
- // This handles problems with lost network connections.
- logsToSend.forEach(logBuffer::remove);
- }
- } catch (Exception e) {
- // We do not report on exceptions here.
- if (!(e instanceof ConnectException)) {
- addStatus(new ErrorStatus("Sending logs to Teamscale failed: " + e.getMessage(), this, e));
- }
- } finally {
- isFlusing.set(false);
- }
- }
- }).whenComplete((result, throwable) -> {
- synchronized (activeLogFlushes) {
- activeLogFlushes.removeIf(CompletableFuture::isDone);
- }
- }));
- }
- }
-
- @Override
- public void stop() {
- // Already flush here once to make sure that we do not miss too much.
- flush();
-
- scheduler.shutdown();
- try {
- if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) {
- scheduler.shutdownNow();
- }
- } catch (InterruptedException e) {
- scheduler.shutdownNow();
- }
-
- // A final flush after the scheduler has been shut down.
- flush();
-
- // Block until all flushes are done
- CompletableFuture.allOf(activeLogFlushes.toArray(new CompletableFuture[0])).join();
-
- super.stop();
- }
-
- public void setTeamscaleClient(ITeamscaleService teamscaleClient) {
- this.teamscaleClient = teamscaleClient;
- }
-
- public void setProfilerId(String profilerId) {
- this.profilerId = profilerId;
- }
-
- /**
- * Add the {@link com.teamscale.jacoco.agent.logging.LogToTeamscaleAppender} to the logging configuration and
- * enable/start it.
- */
- public static boolean addTeamscaleAppenderTo(LoggerContext context, AgentOptions agentOptions) {
- @Nullable TeamscaleClient client = agentOptions.createTeamscaleClient(
- false);
- if (client == null || agentOptions.configurationViaTeamscale == null) {
- return false;
- }
-
- ITeamscaleService serviceClient = client.getService();
- LogToTeamscaleAppender logToTeamscaleAppender = new LogToTeamscaleAppender();
- logToTeamscaleAppender.setContext(context);
- logToTeamscaleAppender.setProfilerId(agentOptions.configurationViaTeamscale.getProfilerId());
- logToTeamscaleAppender.setTeamscaleClient(serviceClient);
- logToTeamscaleAppender.start();
-
- Logger rootLogger = context.getLogger(Logger.ROOT_LOGGER_NAME);
- rootLogger.addAppender(logToTeamscaleAppender);
-
- return true;
- }
-
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/logging/LoggingUtils.java b/agent/src/main/java/com/teamscale/jacoco/agent/logging/LoggingUtils.java
deleted file mode 100644
index d836d8e6f..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/logging/LoggingUtils.java
+++ /dev/null
@@ -1,172 +0,0 @@
-/*-------------------------------------------------------------------------+
-| |
-| Copyright (c) 2009-2018 CQSE GmbH |
-| |
-+-------------------------------------------------------------------------*/
-package com.teamscale.jacoco.agent.logging;
-
-import ch.qos.logback.classic.LoggerContext;
-import ch.qos.logback.classic.joran.JoranConfigurator;
-import ch.qos.logback.classic.spi.ILoggingEvent;
-import ch.qos.logback.classic.spi.IThrowableProxy;
-import ch.qos.logback.classic.spi.ThrowableProxy;
-import ch.qos.logback.classic.spi.ThrowableProxyUtil;
-import ch.qos.logback.core.joran.spi.JoranException;
-import ch.qos.logback.core.util.StatusPrinter;
-import com.teamscale.jacoco.agent.Agent;
-import com.teamscale.jacoco.agent.util.NullOutputStream;
-import com.teamscale.report.util.ILogger;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.io.FileInputStream;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.PrintStream;
-import java.nio.file.Path;
-
-/**
- * Helps initialize the logging framework properly.
- */
-public class LoggingUtils {
-
- /** Returns a logger for the given object's class. */
- public static Logger getLogger(Object object) {
- return LoggerFactory.getLogger(object.getClass());
- }
-
- /** Returns a logger for the given class. */
- public static Logger getLogger(Class> object) {
- return LoggerFactory.getLogger(object);
- }
-
- /** Class to use with try-with-resources to close the logging framework's resources. */
- public static class LoggingResources implements AutoCloseable {
-
- @Override
- public void close() {
- getLoggerContext().stop();
- }
- }
-
- /** Initializes the logging to the default configured in the Jar. */
- public static LoggingResources initializeDefaultLogging() {
- InputStream stream = Agent.class.getResourceAsStream("logback-default.xml");
- reconfigureLoggerContext(stream);
- return new LoggingResources();
- }
-
- /**
- * Returns the logger context.
- */
- public static LoggerContext getLoggerContext() {
- return (LoggerContext) LoggerFactory.getILoggerFactory();
- }
-
- /**
- * Extracts the stack trace from an ILoggingEvent using ThrowableProxyUtil.
- *
- * @param event the logging event containing the exception
- * @return the stack trace as a String, or null if no exception is associated
- */
- public static String getStackTraceFromEvent(ILoggingEvent event) {
- IThrowableProxy throwableProxy = event.getThrowableProxy();
-
- if (throwableProxy != null) {
- // Use ThrowableProxyUtil to convert the IThrowableProxy to a String
- return ThrowableProxyUtil.asString(throwableProxy);
- }
-
- return null;
- }
-
- /**
- * Converts a Throwable to its stack trace as a String.
- *
- * @param throwable the throwable to convert
- * @return the stack trace as a String
- */
- public static String getStackTraceAsString(Throwable throwable) {
- if (throwable == null) {
- return null;
- }
- return ThrowableProxyUtil.asString(new ThrowableProxy(throwable));
- }
-
- /**
- * Reconfigures the logger context to use the configuration XML from the given input stream. Cf. https://logback.qos.ch/manual/configuration.html
- */
- private static void reconfigureLoggerContext(InputStream stream) {
- StatusPrinter.setPrintStream(new PrintStream(new NullOutputStream()));
- LoggerContext loggerContext = getLoggerContext();
- try {
- JoranConfigurator configurator = new JoranConfigurator();
- configurator.setContext(loggerContext);
- loggerContext.reset();
- configurator.doConfigure(stream);
- } catch (JoranException je) {
- // StatusPrinter will handle this
- }
- StatusPrinter.printInCaseOfErrorsOrWarnings(loggerContext);
- }
-
- /**
- * Initializes the logging from the given file. If that is null, uses {@link
- * #initializeDefaultLogging()} instead.
- */
- public static LoggingResources initializeLogging(Path loggingConfigFile) throws IOException {
- if (loggingConfigFile == null) {
- return initializeDefaultLogging();
- }
-
- reconfigureLoggerContext(new FileInputStream(loggingConfigFile.toFile()));
- return new LoggingResources();
- }
-
- /** Initializes debug logging. */
- public static LoggingResources initializeDebugLogging(Path logDirectory) {
- if (logDirectory != null) {
- DebugLogDirectoryPropertyDefiner.filePath = logDirectory;
- }
- InputStream stream = Agent.class.getResourceAsStream("logback-default-debugging.xml");
- reconfigureLoggerContext(stream);
- return new LoggingResources();
- }
-
- /** Wraps the given slf4j logger into an {@link ILogger}. */
- public static ILogger wrap(Logger logger) {
- return new ILogger() {
- @Override
- public void debug(String message) {
- logger.debug(message);
- }
-
- @Override
- public void info(String message) {
- logger.info(message);
- }
-
- @Override
- public void warn(String message) {
- logger.warn(message);
- }
-
- @Override
- public void warn(String message, Throwable throwable) {
- logger.warn(message, throwable);
- }
-
- @Override
- public void error(Throwable throwable) {
- logger.error(throwable.getMessage(), throwable);
- }
-
- @Override
- public void error(String message, Throwable throwable) {
- logger.error(message, throwable);
- }
- };
- }
-
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/options/AgentOptionParseException.java b/agent/src/main/java/com/teamscale/jacoco/agent/options/AgentOptionParseException.java
deleted file mode 100644
index f8b323d6b..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/options/AgentOptionParseException.java
+++ /dev/null
@@ -1,24 +0,0 @@
-package com.teamscale.jacoco.agent.options;
-
-/**
- * Thrown if option parsing fails.
- */
-public class AgentOptionParseException extends Exception {
-
- /**
- * Serialization ID.
- */
- private static final long serialVersionUID = 1L;
-
- public AgentOptionParseException(String message) {
- super(message);
- }
-
- public AgentOptionParseException(Exception e) {
- super(e.getMessage(), e);
- }
-
- public AgentOptionParseException(String message, Throwable cause) {
- super(message, cause);
- }
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/options/AgentOptions.java b/agent/src/main/java/com/teamscale/jacoco/agent/options/AgentOptions.java
deleted file mode 100644
index 4a19f3e3f..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/options/AgentOptions.java
+++ /dev/null
@@ -1,765 +0,0 @@
-/*-------------------------------------------------------------------------+
-| |
-| Copyright (c) 2009-2018 CQSE GmbH |
-| |
-+-------------------------------------------------------------------------*/
-package com.teamscale.jacoco.agent.options;
-
-import com.teamscale.client.EReportFormat;
-import com.teamscale.client.FileSystemUtils;
-import com.teamscale.client.ProxySystemProperties;
-import com.teamscale.client.StringUtils;
-import com.teamscale.client.TeamscaleClient;
-import com.teamscale.client.TeamscaleServer;
-import com.teamscale.jacoco.agent.commandline.Validator;
-import com.teamscale.jacoco.agent.commit_resolution.git_properties.CommitInfo;
-import com.teamscale.jacoco.agent.commit_resolution.git_properties.GitMultiProjectPropertiesLocator;
-import com.teamscale.jacoco.agent.commit_resolution.git_properties.GitPropertiesLocatingTransformer;
-import com.teamscale.jacoco.agent.commit_resolution.git_properties.GitPropertiesLocatorUtils;
-import com.teamscale.jacoco.agent.commit_resolution.git_properties.GitSingleProjectPropertiesLocator;
-import com.teamscale.jacoco.agent.commit_resolution.sapnwdi.NwdiMarkerClassLocatingTransformer;
-import com.teamscale.jacoco.agent.configuration.ConfigurationViaTeamscale;
-import com.teamscale.jacoco.agent.options.sapnwdi.DelayedSapNwdiMultiUploader;
-import com.teamscale.jacoco.agent.options.sapnwdi.SapNwdiApplication;
-import com.teamscale.jacoco.agent.upload.IUploader;
-import com.teamscale.jacoco.agent.upload.LocalDiskUploader;
-import com.teamscale.jacoco.agent.upload.UploaderException;
-import com.teamscale.jacoco.agent.upload.artifactory.ArtifactoryConfig;
-import com.teamscale.jacoco.agent.upload.artifactory.ArtifactoryUploader;
-import com.teamscale.jacoco.agent.upload.azure.AzureFileStorageConfig;
-import com.teamscale.jacoco.agent.upload.azure.AzureFileStorageUploader;
-import com.teamscale.jacoco.agent.upload.delay.DelayedUploader;
-import com.teamscale.jacoco.agent.upload.teamscale.DelayedTeamscaleMultiProjectUploader;
-import com.teamscale.jacoco.agent.upload.teamscale.TeamscaleConfig;
-import com.teamscale.jacoco.agent.upload.teamscale.TeamscaleUploader;
-import com.teamscale.jacoco.agent.util.AgentUtils;
-import com.teamscale.jacoco.agent.util.Assertions;
-import com.teamscale.report.EDuplicateClassFileBehavior;
-import com.teamscale.report.util.ClasspathWildcardIncludeFilter;
-import com.teamscale.report.util.ILogger;
-import kotlin.Pair;
-import org.jacoco.core.runtime.WildcardMatcher;
-import org.jetbrains.annotations.NotNull;
-import org.jetbrains.annotations.Nullable;
-
-import java.io.File;
-import java.io.IOException;
-import java.lang.instrument.Instrumentation;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.nio.file.Paths;
-import java.time.LocalDate;
-import java.time.LocalDateTime;
-import java.time.format.DateTimeFormatter;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Locale;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-import java.util.stream.Stream;
-
-/**
- * Parses agent command line options.
- */
-public class AgentOptions {
-
- /**
- * Can be used to format {@link LocalDate} to the format "yyyy-MM-dd-HH-mm-ss.SSS"
- */
- /* package */ static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter
- .ofPattern("yyyy-MM-dd-HH-mm-ss.SSS", Locale.ENGLISH);
-
- /**
- * The default excludes applied to JaCoCo. These are packages that should never be profiled. Excluding them makes
- * debugging traces easier and reduces trace size and warnings about unmatched classes in Teamscale.
- */
- public static final String DEFAULT_EXCLUDES = "shadow.*:com.sun.*:sun.*:org.eclipse.*:org.junit.*:junit.*:org.apache.*:org.slf4j.*:javax.*:org.gradle.*:java.*:org.jboss.*:org.wildfly.*:org.springframework.*:com.fasterxml.*:jakarta.*:org.aspectj.*:org.h2.*:org.hibernate.*:org.assertj.*:org.mockito.*:org.thymeleaf.*";
-
- private final ILogger logger;
-
- /** See {@link AgentOptions#GIT_PROPERTIES_JAR_OPTION} */
- /* package */ File gitPropertiesJar;
-
- /**
- * Related to {@link AgentOptions#GIT_PROPERTIES_COMMIT_DATE_FORMAT_OPTION}
- */
- public DateTimeFormatter gitPropertiesCommitTimeFormat = null;
-
- /** Option name that allows to specify a jar file that contains the git commit hash in a git.properties file. */
- public static final String GIT_PROPERTIES_JAR_OPTION = "git-properties-jar";
-
- /**
- * Specifies the date format in which the commit timestamp in the git.properties file is formatted.
- */
- public static final String GIT_PROPERTIES_COMMIT_DATE_FORMAT_OPTION = "git-properties-commit-date-format";
-
- /**
- * The original options passed to the agent.
- */
- /* package */ String originalOptionsString;
-
- /** Whether debug logging is active or not. */
- /* package */ boolean debugLogging = false;
-
- /** Explicitly defined log file. */
- /* package */ Path debugLogDirectory = null;
-
- /**
- * The directories and/or zips that contain all class files being profiled. Never null. If this is empty, classes
- * should be dumped to a temporary directory which should be used as the class-dir.
- */
- /* package */ List classDirectoriesOrZips = new ArrayList<>();
-
- /**
- * The logging configuration file.
- */
- /* package */ Path loggingConfig = null;
-
- /**
- * The directory to write the XML traces to.
- */
- private Path outputDirectory;
-
- /** Contains the options related to teamscale-specific proxy settings for http. */
- /* package */ TeamscaleProxyOptions teamscaleProxyOptionsForHttp;
-
- /** Contains the options related to teamscale-specific proxy settings for https. */
- /* package */ TeamscaleProxyOptions teamscaleProxyOptionsForHttps;
-
- /**
- * Additional metadata files to upload together with the coverage XML.
- */
- /* package */ List additionalMetaDataFiles = new ArrayList<>();
-
- /** Whether the agent should be run in testwise coverage mode or normal mode. */
- /* package */ EMode mode = EMode.NORMAL;
-
- /**
- * The interval in minutes for dumping XML data.
- */
- /* package */ int dumpIntervalInMinutes = 480;
-
- /** Whether to dump coverage when the JVM shuts down. */
- /* package */ boolean shouldDumpOnExit = true;
-
- /**
- * Whether to search directories and jar files recursively for git.properties files
- */
- /* package */ boolean searchGitPropertiesRecursively = true;
-
- /**
- * Whether to validate SSL certificates, defaults to true.
- */
- /* package */ boolean validateSsl = true;
-
- /**
- * Whether to ignore duplicate, non-identical class files.
- */
- /* package */ EDuplicateClassFileBehavior duplicateClassFileBehavior = EDuplicateClassFileBehavior.WARN;
-
- /**
- * Include patterns for fully qualified class names to pass on to JaCoCo. See {@link WildcardMatcher} for the
- * pattern syntax. Individual patterns must be separated by ":".
- */
- /* package */ String jacocoIncludes = null;
-
- /**
- * Exclude patterns for fully qualified class names to pass on to JaCoCo. See {@link WildcardMatcher} for the
- * pattern syntax. Individual patterns must be separated by ":".
- */
- /* package */ String jacocoExcludes = DEFAULT_EXCLUDES;
-
- /**
- * Additional user-provided options to pass to JaCoCo.
- */
- /* package */ List> additionalJacocoOptions = new ArrayList<>();
-
- /**
- * The teamscale server to which coverage should be uploaded.
- */
- /* package */ TeamscaleServer teamscaleServer = new TeamscaleServer();
-
- /**
- * How testwise coverage should be handled in test-wise mode.
- */
- /*package*/ ETestwiseCoverageMode testwiseCoverageMode = ETestwiseCoverageMode.EXEC_FILE;
-
- /**
- * The port on which the HTTP server should be listening.
- */
- /* package */ Integer httpServerPort = null;
-
- /**
- * Whether classes without coverage should be skipped from the XML report.
- */
- /* package */ boolean ignoreUncoveredClasses = false;
-
- /**
- * The configuration necessary to upload files to an azure file storage
- */
- /* package */ ArtifactoryConfig artifactoryConfig = new ArtifactoryConfig();
-
- /**
- * The configuration necessary to upload files to an azure file storage
- */
- /* package */ AzureFileStorageConfig azureFileStorageConfig = new AzureFileStorageConfig();
-
- /**
- * The configuration necessary when used in an SAP NetWeaver Java environment.
- */
- /* package */ List sapNetWeaverJavaApplications = new ArrayList<>();
-
- /**
- * Whether to obfuscate security related configuration options when dumping them into the log or onto the console or
- * not.
- */
- /* package */ boolean obfuscateSecurityRelatedOutputs = true;
-
- /**
- * Helper class that holds the process information, Teamscale client and profiler configuration and allows to
- * continuously update the profiler's info in Teamscale in the background via
- * {@link ConfigurationViaTeamscale#startHeartbeatThreadAndRegisterShutdownHook()}.
- */
- public ConfigurationViaTeamscale configurationViaTeamscale;
-
- public AgentOptions(ILogger logger) {
- this.logger = logger;
- setParentOutputDirectory(AgentUtils.getMainTempDirectory().resolve("coverage"));
- teamscaleProxyOptionsForHttp = new TeamscaleProxyOptions(
- ProxySystemProperties.Protocol.HTTP, logger);
- teamscaleProxyOptionsForHttps = new TeamscaleProxyOptions(
- ProxySystemProperties.Protocol.HTTPS, logger);
- }
-
- /** @see #debugLogging */
- public boolean isDebugLogging() {
- return debugLogging;
- }
-
- /** @see #debugLogDirectory */
- public Path getDebugLogDirectory() {
- return debugLogDirectory;
- }
-
- /**
- * @see #originalOptionsString
- */
- public String getOriginalOptionsString() {
- return originalOptionsString;
- }
-
- /**
- * Remove parts of the API key for security reasons from the options string. String is used for logging purposes.
- *
- * Given, for example, "config-file=jacocoagent.properties,teamscale-access-token=unlYgehaYYYhbPAegNWV3WgjOzxkmNHn"
- * we produce a string with obfuscation:
- * "config-file=jacocoagent.properties,teamscale-access-token=************mNHn"
- */
- public String getObfuscatedOptionsString() {
- if (getOriginalOptionsString() == null) {
- return "";
- }
-
- Pattern pattern = Pattern.compile("(.*-access-token=)([^,]+)(.*)");
- Matcher match = pattern.matcher(getOriginalOptionsString());
- if (match.find()) {
- String apiKey = match.group(2);
- String obfuscatedApiKey = String.format("************%s", apiKey.substring(Math.max(0,
- apiKey.length() - 4)));
- return String.format("%s%s%s", match.group(1), obfuscatedApiKey, match.group(3));
- }
-
- return getOriginalOptionsString();
- }
-
- /**
- * Validates the options and returns a validator with all validation errors.
- */
- /* package */ Validator getValidator() {
- Validator validator = new Validator();
-
- validateFilePaths(validator);
-
- if (loggingConfig != null) {
- validateLoggingConfig(validator);
- }
-
- validateTeamscaleUploadConfig(validator);
-
- validateUploadConfig(validator);
-
- validateSapNetWeaverConfig(validator);
-
- if (useTestwiseCoverageMode()) {
- validateTestwiseCoverageConfig(validator);
- }
-
- return validator;
- }
-
- private void validateFilePaths(Validator validator) {
- for (File path : classDirectoriesOrZips) {
- validator.isTrue(path.exists(), "Path '" + path + "' does not exist");
- validator.isTrue(path.canRead(), "Path '" + path + "' is not readable");
- }
- }
-
- private void validateLoggingConfig(Validator validator) {
- validator.ensure(() -> {
- Assertions.isTrue(Files.exists(loggingConfig),
- "The path provided for the logging configuration does not exist: " + loggingConfig);
- Assertions.isTrue(Files.isRegularFile(loggingConfig),
- "The path provided for the logging configuration is not a file: " + loggingConfig);
- Assertions.isTrue(Files.isReadable(loggingConfig),
- "The file provided for the logging configuration is not readable: " + loggingConfig);
- Assertions.isTrue("xml".equalsIgnoreCase(FileSystemUtils.getFileExtension(loggingConfig.toFile())),
- "The logging configuration file must have the file extension .xml and be a valid XML file");
- });
- }
-
- private void validateTeamscaleUploadConfig(Validator validator) {
- validator.isTrue(
- teamscaleServer.hasAllFieldsNull() || teamscaleServer.canConnectToTeamscale() || teamscaleServer.isConfiguredForSingleProjectTeamscaleUpload() || teamscaleServer.isConfiguredForMultiProjectUpload(),
- "You did provide some options prefixed with 'teamscale-', but not all required ones!");
-
- validator.isFalse(teamscaleServer.isConfiguredForMultiProjectUpload() && (teamscaleServer.revision != null
- || teamscaleServer.commit != null),
- "You tried to provide a commit to upload to directly. This is not possible, since you" +
- " did not provide the 'teamscale-project' to upload to. Please either specify the 'teamscale-project'" +
- " property, or provide the respective projects and commits via all the profiled Jar/War/Ear/...s' " +
- " git.properties files.");
-
- validator.isTrue(teamscaleServer.revision == null || teamscaleServer.commit == null,
- "'" + TeamscaleConfig.TEAMSCALE_REVISION_OPTION + "' and '" + TeamscaleConfig.TEAMSCALE_REVISION_MANIFEST_JAR_OPTION + "' are incompatible with '" + TeamscaleConfig.TEAMSCALE_COMMIT_OPTION + "' and '" +
- TeamscaleConfig.TEAMSCALE_COMMIT_MANIFEST_JAR_OPTION + "'.");
-
- validator.isTrue(teamscaleServer.project == null || teamscaleServer.partition != null,
- "You configured a 'teamscale-project' but no 'teamscale-partition' to upload to.");
- }
-
- private void validateUploadConfig(Validator validator) {
- validator.isTrue((artifactoryConfig.hasAllRequiredFieldsSet() || artifactoryConfig
- .hasAllRequiredFieldsNull()),
- String.format("If you want to upload data to Artifactory you need to provide " +
- "'%s', '%s' and an authentication method (either '%s' and '%s' or '%s') ",
- ArtifactoryConfig.ARTIFACTORY_URL_OPTION,
- ArtifactoryConfig.ARTIFACTORY_PARTITION,
- ArtifactoryConfig.ARTIFACTORY_USER_OPTION, ArtifactoryConfig.ARTIFACTORY_PASSWORD_OPTION,
- ArtifactoryConfig.ARTIFACTORY_API_KEY_OPTION));
-
- validator.isTrue((azureFileStorageConfig.hasAllRequiredFieldsSet() || azureFileStorageConfig
- .hasAllRequiredFieldsNull()),
- "If you want to upload data to an Azure file storage you need to provide both " +
- "'azure-url' and 'azure-key' ");
-
- long configuredStores = Stream
- .of(artifactoryConfig.hasAllRequiredFieldsSet(), azureFileStorageConfig.hasAllRequiredFieldsSet(),
- teamscaleServer.isConfiguredForSingleProjectTeamscaleUpload(),
- teamscaleServer.isConfiguredForMultiProjectUpload()).filter(x -> x)
- .count();
-
- validator.isTrue(configuredStores <= 1, "You cannot configure multiple upload stores, " +
- "such as a Teamscale instance, upload URL, Azure file storage or artifactory");
- }
-
- private void validateSapNetWeaverConfig(Validator validator) {
- if (sapNetWeaverJavaApplications.isEmpty()) {
- return;
- }
-
- validator.isTrue(teamscaleServer.project == null,
- "You provided an SAP NWDI applications config and a teamscale-project. This is not allowed. " +
- "The project must be specified via sap-nwdi-applications!");
-
- validator.isTrue(teamscaleServer.project != null || teamscaleServer.isConfiguredForMultiProjectUpload(),
- "You provided an SAP NWDI applications config, but the 'teamscale-' upload options are incomplete.");
-
- }
-
- private void validateTestwiseCoverageConfig(Validator validator) {
- validator.isTrue(httpServerPort != null,
- "You use 'mode=testwise' but did not specify the required option 'http-server-port'!");
-
- validator.isTrue(testwiseCoverageMode != ETestwiseCoverageMode.TEAMSCALE_UPLOAD
- || teamscaleServer.isConfiguredForSingleProjectTeamscaleUpload(),
- "You use 'tia-mode=teamscale-upload' but did not set all required 'teamscale-' fields to facilitate" +
- " a connection to Teamscale!");
-
- validator.isTrue(
- testwiseCoverageMode != ETestwiseCoverageMode.TEAMSCALE_UPLOAD || teamscaleServer.hasCommitOrRevision(),
- "You use 'tia-mode=teamscale-upload' but did not provide a revision or commit via the agent's '" + TeamscaleConfig.TEAMSCALE_REVISION_OPTION + "', '" +
- TeamscaleConfig.TEAMSCALE_REVISION_MANIFEST_JAR_OPTION + "', '" + TeamscaleConfig.TEAMSCALE_COMMIT_OPTION +
- "', '" + TeamscaleConfig.TEAMSCALE_COMMIT_MANIFEST_JAR_OPTION + "' or '" +
- AgentOptions.GIT_PROPERTIES_JAR_OPTION + "' option." +
- " Auto-detecting the git.properties is currently not supported in this mode.");
- }
-
- /**
- * Creates a {@link TeamscaleClient} based on the agent options. Returns null if the user did not fully configure a
- * Teamscale connection.
- */
- public @Nullable TeamscaleClient createTeamscaleClient(boolean requireSingleProjectUploadConfig) {
- if (teamscaleServer.isConfiguredForSingleProjectTeamscaleUpload() ||
- !requireSingleProjectUploadConfig && teamscaleServer.isConfiguredForServerConnection()) {
- return new TeamscaleClient(teamscaleServer.url.toString(), teamscaleServer.userName,
- teamscaleServer.userAccessToken, teamscaleServer.project,
- AgentUtils.USER_AGENT);
- }
- return null;
- }
-
- /** All available upload methods. */
- /*package*/ enum EUploadMethod {
- /** Saving coverage files on disk. */
- LOCAL_DISK,
- /** Sending coverage to a single Teamscale project. */
- TEAMSCALE_SINGLE_PROJECT,
- /** Sending coverage to multiple Teamscale projects. */
- TEAMSCALE_MULTI_PROJECT,
- /** Sending coverage to multiple Teamscale projects based on SAP NWDI application definitions. */
- SAP_NWDI_TEAMSCALE,
- /** Sending coverage to an Artifactory. */
- ARTIFACTORY,
- /** Sending coverage to Azure file storage. */
- AZURE_FILE_STORAGE,
- }
-
- /** Determines the upload method that should be used based on the set options. */
- /*package*/ EUploadMethod determineUploadMethod() {
- if (artifactoryConfig.hasAllRequiredFieldsSet()) {
- return EUploadMethod.ARTIFACTORY;
- }
- if (azureFileStorageConfig.hasAllRequiredFieldsSet()) {
- return EUploadMethod.AZURE_FILE_STORAGE;
- }
- if (!sapNetWeaverJavaApplications.isEmpty()) {
- return EUploadMethod.SAP_NWDI_TEAMSCALE;
- }
- if (teamscaleServer.isConfiguredForMultiProjectUpload()) {
- return EUploadMethod.TEAMSCALE_MULTI_PROJECT;
- }
- if (teamscaleServer.isConfiguredForSingleProjectTeamscaleUpload()) {
- return EUploadMethod.TEAMSCALE_SINGLE_PROJECT;
- }
- return EUploadMethod.LOCAL_DISK;
- }
-
- /**
- * Creates an uploader for the coverage XMLs.
- */
- public IUploader createUploader(Instrumentation instrumentation) throws UploaderException {
- EUploadMethod uploadMethod = determineUploadMethod();
- switch (uploadMethod) {
- case TEAMSCALE_MULTI_PROJECT:
- return createTeamscaleMultiProjectUploader(instrumentation);
- case TEAMSCALE_SINGLE_PROJECT:
- return createTeamscaleSingleProjectUploader(instrumentation);
- case ARTIFACTORY:
- return createArtifactoryUploader(instrumentation);
- case AZURE_FILE_STORAGE:
- return new AzureFileStorageUploader(azureFileStorageConfig,
- additionalMetaDataFiles);
- case SAP_NWDI_TEAMSCALE: {
- logger.info("NWDI configuration detected. The Agent will try and" +
- " auto-detect commit information by searching all profiled Jar/War/Ear/... files.");
- return createNwdiTeamscaleUploader(instrumentation);
- }
- case LOCAL_DISK:
- return new LocalDiskUploader();
- default:
- throw new RuntimeException("Unhandled upload method " + uploadMethod + "."
- + " This is a bug, please report this to CQSE.");
- }
- }
-
- @NotNull
- private IUploader createArtifactoryUploader(Instrumentation instrumentation) throws UploaderException {
- if (gitPropertiesJar != null) {
- logger.info("You did not provide a commit to upload to directly, so the Agent will try to" +
- "auto-detect it by searching the provided " + GIT_PROPERTIES_JAR_OPTION + " at " +
- gitPropertiesJar.getAbsolutePath() + " for a git.properties file.");
- artifactoryConfig.commitInfo = ArtifactoryConfig.parseGitProperties(gitPropertiesJar,
- this.searchGitPropertiesRecursively, this.gitPropertiesCommitTimeFormat);
- }
- if (!artifactoryConfig.hasCommitInfo()) {
- logger.info("You did not provide a commit to upload to directly, so the Agent will try and" +
- " auto-detect it by searching all profiled Jar/War/Ear/... files for a git.properties file.");
- return createDelayedArtifactoryUploader(instrumentation);
- }
- return new ArtifactoryUploader(artifactoryConfig,
- additionalMetaDataFiles, getReportFormat());
- }
-
- @NotNull
- private IUploader createTeamscaleSingleProjectUploader(Instrumentation instrumentation) {
- if (teamscaleServer.hasCommitOrRevision()) {
- return new TeamscaleUploader(teamscaleServer);
- }
-
- DelayedUploader uploader = createDelayedSingleProjectTeamscaleUploader();
-
- if (gitPropertiesJar != null) {
- logger.info("You did not provide a commit to upload to directly, so the Agent will try to" +
- "auto-detect it by searching the provided " + GIT_PROPERTIES_JAR_OPTION + " at " +
- gitPropertiesJar.getAbsolutePath() + " for a git.properties file.");
- startGitPropertiesSearchInJarFile(uploader, gitPropertiesJar);
- return uploader;
- }
-
- logger.info("You did not provide a commit to upload to directly, so the Agent will try and" +
- " auto-detect it by searching all profiled Jar/War/Ear/... files for a git.properties file.");
- registerSingleGitPropertiesLocator(uploader, instrumentation);
- return uploader;
- }
-
- @NotNull
- private DelayedTeamscaleMultiProjectUploader createTeamscaleMultiProjectUploader(
- Instrumentation instrumentation) {
- DelayedTeamscaleMultiProjectUploader uploader = new DelayedTeamscaleMultiProjectUploader(
- (project, commitInfo) -> {
- if (commitInfo.preferCommitDescriptorOverRevision || StringUtils.isEmpty(commitInfo.revision)) {
- return teamscaleServer.withProjectAndCommit(project, commitInfo.commit);
- }
- return teamscaleServer.withProjectAndRevision(project, commitInfo.revision);
- });
-
- if (gitPropertiesJar != null) {
- logger.info(
- "You did not provide a Teamscale project to upload to directly, so the Agent will try and" +
- " auto-detect it by searching the provided " + GIT_PROPERTIES_JAR_OPTION + " at " +
- gitPropertiesJar.getAbsolutePath() + " for a git.properties file.");
-
- startMultiGitPropertiesFileSearchInJarFile(uploader, gitPropertiesJar);
- return uploader;
- }
-
- logger.info("You did not provide a Teamscale project to upload to directly, so the Agent will try and" +
- " auto-detect it by searching all profiled Jar/War/Ear/... files for git.properties files" +
- " with the 'teamscale.project' field set.");
- registerMultiGitPropertiesLocator(uploader, instrumentation);
- return uploader;
- }
-
- private void startGitPropertiesSearchInJarFile(DelayedUploader uploader,
- File gitPropertiesJar) {
- GitSingleProjectPropertiesLocator locator = new GitSingleProjectPropertiesLocator<>(uploader,
- GitPropertiesLocatorUtils::getProjectRevisionsFromGitProperties, this.searchGitPropertiesRecursively,
- this.gitPropertiesCommitTimeFormat);
- locator.searchFileForGitPropertiesAsync(gitPropertiesJar, true);
- }
-
- private void registerSingleGitPropertiesLocator(DelayedUploader uploader,
- Instrumentation instrumentation) {
- GitSingleProjectPropertiesLocator locator = new GitSingleProjectPropertiesLocator<>(uploader,
- GitPropertiesLocatorUtils::getProjectRevisionsFromGitProperties, this.searchGitPropertiesRecursively,
- this.gitPropertiesCommitTimeFormat);
- instrumentation.addTransformer(new GitPropertiesLocatingTransformer(locator, getLocationIncludeFilter()));
- }
-
- private DelayedUploader createDelayedSingleProjectTeamscaleUploader() {
- return new DelayedUploader<>(
- projectAndCommit -> {
- if (!StringUtils.isEmpty(projectAndCommit.getProject()) && !teamscaleServer.project
- .equals(projectAndCommit.getProject())) {
- logger.warn(
- "Teamscale project '" + teamscaleServer.project + "' specified in the agent configuration is not the same as the Teamscale project '" + projectAndCommit.getProject() + "' specified in git.properties file(s). Proceeding to upload to the" +
- " Teamscale project '" + teamscaleServer.project + "' specified in the agent configuration.");
- }
- if (projectAndCommit.getCommitInfo().preferCommitDescriptorOverRevision ||
- StringUtils.isEmpty(projectAndCommit.getCommitInfo().revision)) {
- teamscaleServer.commit = projectAndCommit.getCommitInfo().commit;
- } else {
- teamscaleServer.revision = projectAndCommit.getCommitInfo().revision;
- }
- return new TeamscaleUploader(teamscaleServer);
- }, outputDirectory);
- }
-
- private void startMultiGitPropertiesFileSearchInJarFile(DelayedTeamscaleMultiProjectUploader uploader,
- File gitPropertiesJar) {
- GitMultiProjectPropertiesLocator locator = new GitMultiProjectPropertiesLocator(uploader,
- this.searchGitPropertiesRecursively, this.gitPropertiesCommitTimeFormat);
- locator.searchFileForGitPropertiesAsync(gitPropertiesJar, true);
- }
-
- private void registerMultiGitPropertiesLocator(DelayedTeamscaleMultiProjectUploader uploader,
- Instrumentation instrumentation) {
- GitMultiProjectPropertiesLocator locator = new GitMultiProjectPropertiesLocator(uploader,
- this.searchGitPropertiesRecursively, this.gitPropertiesCommitTimeFormat);
- instrumentation.addTransformer(new GitPropertiesLocatingTransformer(locator, getLocationIncludeFilter()));
- }
-
- private IUploader createDelayedArtifactoryUploader(Instrumentation instrumentation) {
- DelayedUploader uploader = new DelayedUploader<>(
- commitInfo -> {
- artifactoryConfig.commitInfo = commitInfo;
- return new ArtifactoryUploader(artifactoryConfig, additionalMetaDataFiles,
- getReportFormat());
- }, outputDirectory);
- GitSingleProjectPropertiesLocator locator = new GitSingleProjectPropertiesLocator<>(
- uploader,
- GitPropertiesLocatorUtils::getCommitInfoFromGitProperties,
- this.searchGitPropertiesRecursively, this.gitPropertiesCommitTimeFormat);
- instrumentation.addTransformer(new GitPropertiesLocatingTransformer(locator, getLocationIncludeFilter()));
- return uploader;
- }
-
- private IUploader createNwdiTeamscaleUploader(Instrumentation instrumentation) {
- DelayedSapNwdiMultiUploader uploader = new DelayedSapNwdiMultiUploader(
- (commit, application) -> new TeamscaleUploader(
- teamscaleServer.withProjectAndCommit(application.getTeamscaleProject(), commit)));
- instrumentation.addTransformer(new NwdiMarkerClassLocatingTransformer(uploader, getLocationIncludeFilter(),
- sapNetWeaverJavaApplications));
- return uploader;
- }
-
- private EReportFormat getReportFormat() {
- if (useTestwiseCoverageMode()) {
- return EReportFormat.TESTWISE_COVERAGE;
- }
- return EReportFormat.JACOCO;
- }
-
- /**
- * @see #classDirectoriesOrZips
- */
- public List getClassDirectoriesOrZips() {
- return classDirectoriesOrZips;
- }
-
- /** @see #teamscaleServer */
- public TeamscaleServer getTeamscaleServerOptions() {
- return teamscaleServer;
- }
-
- /**
- * Get the directory to which the coverage files are written to
- */
- public Path getOutputDirectory() {
- return outputDirectory;
- }
-
- /**
- * Creates a new file with the given prefix, extension and current timestamp and ensures that the parent folder
- * actually exists.
- */
- public File createNewFileInOutputDirectory(String prefix, String extension) throws IOException {
- FileSystemUtils.ensureDirectoryExists(outputDirectory.toFile());
- return outputDirectory.resolve(prefix + "-" + LocalDateTime.now().format(DATE_TIME_FORMATTER) + "." + extension)
- .toFile();
- }
-
- /**
- * Creates a new file with the given prefix, extension and current timestamp and ensures that the parent folder
- * actually exists. One output folder is created per partition.
- */
- public File createNewFileInPartitionOutputDirectory(String prefix, String extension) throws IOException {
- Path partitionOutputDir = outputDirectory.resolve(safeFolderName(getTeamscaleServerOptions().partition));
- FileSystemUtils.ensureDirectoryExists(partitionOutputDir.toFile());
- return partitionOutputDir.resolve(
- prefix + "-" + LocalDateTime.now().format(DATE_TIME_FORMATTER) + "." + extension).toFile();
- }
-
- private static Path safeFolderName(String folderName) {
- String result = folderName.replaceAll("[<>:\"/|?*]", "")
- .replaceAll("\\.+", "dot")
- .replaceAll("\\x00", "")
- .replaceAll("[. ]$", "");
-
- if (result.isEmpty()) {
- return Paths.get("default");
- } else {
- return Paths.get(result);
- }
- }
-
- /**
- * Sets the parent of the output directory for this run. The output directory itself will be created in this folder
- * is named after the current timestamp with the format yyyy-MM-dd-HH-mm-ss.SSS
- */
- public void setParentOutputDirectory(Path outputDirectoryParent) {
- outputDirectory = outputDirectoryParent.resolve(LocalDateTime.now().format(DATE_TIME_FORMATTER));
- }
-
- /**
- * @see #dumpIntervalInMinutes
- */
- public int getDumpIntervalInMinutes() {
- return dumpIntervalInMinutes;
- }
-
- /**
- * @see #duplicateClassFileBehavior
- */
- public EDuplicateClassFileBehavior getDuplicateClassFileBehavior() {
- return duplicateClassFileBehavior;
- }
-
- /** Returns whether the config indicates to use Test Impact mode. */
- public boolean useTestwiseCoverageMode() {
- return mode == EMode.TESTWISE;
- }
-
- /**
- * Returns the port at which the http server should listen for test execution information or null if disabled.
- */
- public Integer getHttpServerPort() {
- return httpServerPort;
- }
-
- /**
- * @see #loggingConfig
- */
- public Path getLoggingConfig() {
- return loggingConfig;
- }
-
- /**
- * @see #validateSsl
- */
- public boolean shouldValidateSsl() {
- return validateSsl;
- }
-
- /**
- * @see #obfuscateSecurityRelatedOutputs
- */
- public boolean shouldObfuscateSecurityRelatedOutputs() {
- return obfuscateSecurityRelatedOutputs;
- }
-
- /**
- * @see #jacocoIncludes
- * @see #jacocoExcludes
- */
- public ClasspathWildcardIncludeFilter getLocationIncludeFilter() {
- return new ClasspathWildcardIncludeFilter(jacocoIncludes, jacocoExcludes);
- }
-
- /** Whether coverage should be dumped in regular intervals. */
- public boolean shouldDumpInIntervals() {
- return dumpIntervalInMinutes > 0;
- }
-
- /** Whether coverage should be dumped on JVM shutdown. */
- public boolean shouldDumpOnExit() {
- return shouldDumpOnExit;
- }
-
- public ETestwiseCoverageMode getTestwiseCoverageMode() {
- return testwiseCoverageMode;
- }
-
- /** @see #ignoreUncoveredClasses */
- public boolean shouldIgnoreUncoveredClasses() {
- return ignoreUncoveredClasses;
- }
-
- /** @return the {@link TeamscaleProxyOptions} for the given protocol. */
- public TeamscaleProxyOptions getTeamscaleProxyOptions(ProxySystemProperties.Protocol protocol) {
- if (protocol == ProxySystemProperties.Protocol.HTTP) {
- return teamscaleProxyOptionsForHttp;
- }
- return teamscaleProxyOptionsForHttps;
- }
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/options/AgentOptionsParser.java b/agent/src/main/java/com/teamscale/jacoco/agent/options/AgentOptionsParser.java
deleted file mode 100644
index 2e6b9821d..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/options/AgentOptionsParser.java
+++ /dev/null
@@ -1,551 +0,0 @@
-/*-------------------------------------------------------------------------+
-| |
-| Copyright (c) 2009-2018 CQSE GmbH |
-| |
-+-------------------------------------------------------------------------*/
-package com.teamscale.jacoco.agent.options;
-
-import com.teamscale.client.FileSystemUtils;
-import com.teamscale.client.HttpUtils;
-import com.teamscale.client.ProxySystemProperties;
-import com.teamscale.client.StringUtils;
-import com.teamscale.client.TeamscaleProxySystemProperties;
-import com.teamscale.jacoco.agent.commandline.Validator;
-import com.teamscale.jacoco.agent.configuration.AgentOptionReceiveException;
-import com.teamscale.jacoco.agent.configuration.ConfigurationViaTeamscale;
-import com.teamscale.jacoco.agent.options.sapnwdi.SapNwdiApplication;
-import com.teamscale.jacoco.agent.upload.artifactory.ArtifactoryConfig;
-import com.teamscale.jacoco.agent.upload.azure.AzureFileStorageConfig;
-import com.teamscale.jacoco.agent.upload.teamscale.TeamscaleConfig;
-import com.teamscale.report.EDuplicateClassFileBehavior;
-import com.teamscale.report.util.ILogger;
-import kotlin.Pair;
-import okhttp3.HttpUrl;
-import org.jetbrains.annotations.Nullable;
-import org.jetbrains.annotations.VisibleForTesting;
-
-import java.io.File;
-import java.io.FileNotFoundException;
-import java.io.IOException;
-import java.nio.file.InvalidPathException;
-import java.nio.file.Path;
-import java.nio.file.Paths;
-import java.time.format.DateTimeFormatter;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.List;
-import java.util.stream.Collectors;
-
-import static com.teamscale.jacoco.agent.upload.artifactory.ArtifactoryConfig.ARTIFACTORY_GIT_PROPERTIES_COMMIT_DATE_FORMAT_OPTION;
-import static com.teamscale.jacoco.agent.upload.artifactory.ArtifactoryConfig.ARTIFACTORY_GIT_PROPERTIES_JAR_OPTION;
-import static java.util.stream.Collectors.joining;
-
-/**
- * Parses agent command line options.
- */
-public class AgentOptionsParser {
-
- /** The name of the option for providing the logging config. */
- public static final String LOGGING_CONFIG_OPTION = "logging-config";
-
- /** The name of the option for providing the config file. */
- public static final String CONFIG_FILE_OPTION = "config-file";
-
- /** Character which starts a comment in the config file. */
- private static final String COMMENT_PREFIX = "#";
-
- /** The name of the option that enables debug logging. */
- public static final String DEBUG = "debug";
-
- private final ILogger logger;
- private final FilePatternResolver filePatternResolver;
- private final TeamscaleConfig teamscaleConfig;
- private final String environmentConfigId;
- private final String environmentConfigFile;
- @Nullable
- private final TeamscaleCredentials credentials;
- @Nullable
- private final String environmentAccessToken;
- private final List collectedErrors;
-
- /**
- * Parses the given command-line options.
- *
- * @param environmentConfigId The Profiler configuration ID given via an environment variable.
- * @param environmentConfigFile The Profiler configuration file given via an environment variable.
- * @param credentials Optional Teamscale credentials from a teamscale.properties file.
- * @param environmentAccessToken Optional access token for accessing Teamscale, read from an env variable.
- */
- public static Pair> parse(String optionsString, String environmentConfigId,
- String environmentConfigFile, @Nullable TeamscaleCredentials credentials,
- @Nullable String environmentAccessToken,
- ILogger logger) throws AgentOptionParseException, AgentOptionReceiveException {
- AgentOptionsParser parser = new AgentOptionsParser(logger, environmentConfigId, environmentConfigFile,
- credentials, environmentAccessToken);
- AgentOptions options = parser.parse(optionsString);
- return new Pair<>(options, parser.getCollectedErrors());
- }
-
- @VisibleForTesting
- AgentOptionsParser(ILogger logger, String environmentConfigId, String environmentConfigFile,
- @Nullable TeamscaleCredentials credentials, @Nullable String environmentAccessToken) {
- this.logger = logger;
- this.filePatternResolver = new FilePatternResolver(logger);
- this.teamscaleConfig = new TeamscaleConfig(logger, filePatternResolver);
- this.environmentConfigId = environmentConfigId;
- this.environmentConfigFile = environmentConfigFile;
- this.credentials = credentials;
- this.environmentAccessToken = environmentAccessToken;
- this.collectedErrors = new ArrayList<>();
- }
-
- private List getCollectedErrors() {
- return collectedErrors;
- }
-
- /**
- * Throw the first collected exception, if present.
- */
- @VisibleForTesting
- public void throwOnCollectedErrors() throws Exception {
- for (Exception e : collectedErrors) {
- throw e;
- }
- }
-
- /**
- * Parses the given command-line options.
- */
- /* package */ AgentOptions parse(
- String optionsString) throws AgentOptionParseException, AgentOptionReceiveException {
-
- if (optionsString == null) {
- optionsString = "";
- }
- logger.debug("Parsing options: " + optionsString);
-
- AgentOptions options = new AgentOptions(logger);
- options.originalOptionsString = optionsString;
-
- presetCredentialOptions(options);
-
- if (!StringUtils.isEmpty(optionsString)) {
- String[] optionParts = optionsString.split(",");
- for (String optionPart : optionParts) {
- try {
- handleOptionPart(options, optionPart);
- } catch (Exception e) {
- collectedErrors.add(e);
- }
- }
- }
-
- // we have to put the proxy options into system properties before reading the configuration from Teamscale as we
- // might need them to connect to Teamscale
- putTeamscaleProxyOptionsIntoSystemProperties(options);
-
- handleConfigId(options);
- handleConfigFile(options);
-
- Validator validator = options.getValidator();
- if (!validator.isValid()) {
- collectedErrors.add(new AgentOptionParseException("Invalid options given: " + validator.getErrorMessage()));
- }
-
- return options;
- }
-
- private void presetCredentialOptions(AgentOptions options) {
- if (credentials != null) {
- options.teamscaleServer.url = credentials.url;
- options.teamscaleServer.userName = credentials.userName;
- options.teamscaleServer.userAccessToken = credentials.accessKey;
- }
- if (environmentAccessToken != null) {
- options.teamscaleServer.userAccessToken = environmentAccessToken;
- }
- }
-
- /**
- * Stores the agent options for proxies in the {@link TeamscaleProxySystemProperties} and overwrites the password
- * with the password found in the proxy-password-file if necessary.
- */
- @VisibleForTesting
- public static void putTeamscaleProxyOptionsIntoSystemProperties(AgentOptions options) {
- options.getTeamscaleProxyOptions(ProxySystemProperties.Protocol.HTTP)
- .putTeamscaleProxyOptionsIntoSystemProperties();
- options.getTeamscaleProxyOptions(ProxySystemProperties.Protocol.HTTPS)
- .putTeamscaleProxyOptionsIntoSystemProperties();
- }
-
- private void handleConfigId(AgentOptions options) throws AgentOptionReceiveException, AgentOptionParseException {
- if (environmentConfigId != null) {
- if (options.teamscaleServer.configId != null) {
- logger.warn(
- "You specified an ID for a profiler configuration in Teamscale both in the agent options and using an environment variable." +
- " The environment variable will override the ID specified using the agent options." +
- " Please use one or the other.");
- }
- handleOptionPart(options, "config-id=" + environmentConfigId);
- }
-
- readConfigFromTeamscale(options);
- }
-
- private void handleConfigFile(AgentOptions options) throws AgentOptionParseException {
- if (environmentConfigFile != null) {
- handleOptionPart(options, "config-file=" + environmentConfigFile);
- }
-
- if (environmentConfigId != null && environmentConfigFile != null) {
- logger.warn("You specified both an ID for a profiler configuration in Teamscale and a config file." +
- " The config file will override the Teamscale configuration." +
- " Please use one or the other.");
- }
- }
-
- /**
- * Parses and stores the given option in the format key=value.
- */
- private void handleOptionPart(AgentOptions options,
- String optionPart) throws AgentOptionParseException {
- Pair keyAndValue = parseOption(optionPart);
- handleOption(options, keyAndValue.getFirst(), keyAndValue.getSecond());
- }
-
- /**
- * Parses and stores the option with the given key and value.
- */
- private void handleOption(AgentOptions options,
- String key, String value) throws AgentOptionParseException {
- if (key.startsWith(DEBUG)) {
- handleDebugOption(options, value);
- return;
- }
- if (key.startsWith("jacoco-")) {
- options.additionalJacocoOptions.add(new kotlin.Pair<>(key.substring(7), value));
- return;
- }
- if (key.startsWith("teamscale-") && teamscaleConfig.handleTeamscaleOptions(options.teamscaleServer, key,
- value)) {
- return;
- }
- if (key.startsWith("artifactory-") && ArtifactoryConfig
- .handleArtifactoryOptions(options.artifactoryConfig, key, value)) {
- return;
- }
- if (key.startsWith("azure-") && AzureFileStorageConfig
- .handleAzureFileStorageOptions(options.azureFileStorageConfig, key,
- value)) {
- return;
- }
- if (key.startsWith("proxy-") && handleProxyOptions(options, StringUtils.stripPrefix(key, "proxy-"), value
- )) {
- return;
- }
- if (handleAgentOptions(options, key, value)) {
- return;
- }
- throw new AgentOptionParseException("Unknown option: " + key);
- }
-
- private boolean handleProxyOptions(AgentOptions options, String key,
- String value) throws AgentOptionParseException {
- String httpsPrefix = ProxySystemProperties.Protocol.HTTPS + "-";
- if (key.startsWith(httpsPrefix)
- && options.getTeamscaleProxyOptions(ProxySystemProperties.Protocol.HTTPS)
- .handleTeamscaleProxyOptions(StringUtils.stripPrefix(
- key, httpsPrefix), value)) {
- return true;
- }
-
- String httpPrefix = ProxySystemProperties.Protocol.HTTP + "-";
- if (key.startsWith(httpPrefix)
- && options.getTeamscaleProxyOptions(ProxySystemProperties.Protocol.HTTP)
- .handleTeamscaleProxyOptions(StringUtils.stripPrefix(
- key, httpPrefix), value)) {
- return true;
- }
-
- if (key.equals("password-file")) {
- Path proxyPasswordPath = parsePath(filePatternResolver, key, value);
- options.getTeamscaleProxyOptions(ProxySystemProperties.Protocol.HTTPS)
- .setProxyPasswordPath(proxyPasswordPath);
- options.getTeamscaleProxyOptions(ProxySystemProperties.Protocol.HTTP)
- .setProxyPasswordPath(proxyPasswordPath);
- return true;
- }
- return false;
- }
-
- /** Parses and stores the debug logging file path if given. */
- private void handleDebugOption(AgentOptions options, String value) {
- if (value.equalsIgnoreCase("false")) {
- return;
- }
- options.debugLogging = true;
- if (!value.isEmpty() && !value.equalsIgnoreCase("true")) {
- options.debugLogDirectory = Paths.get(value);
- }
- }
-
- private Pair parseOption(String optionPart) throws AgentOptionParseException {
- String[] keyAndValue = optionPart.split("=", 2);
- if (keyAndValue.length < 2) {
- throw new AgentOptionParseException("Got an option without any value: " + optionPart);
- }
-
- String key = keyAndValue[0].toLowerCase();
- String value = keyAndValue[1];
-
- // Remove quotes, which may be used to pass arguments with spaces via
- // the command line
- if (value.startsWith("\"") && value.endsWith("\"")) {
- value = value.substring(1, value.length() - 1);
- }
- return new Pair<>(key, value);
- }
-
- /**
- * Handles all common command line options for the agent.
- *
- * @return true if it has successfully processed the given option.
- */
- private boolean handleAgentOptions(AgentOptions options, String key, String value)
- throws AgentOptionParseException {
- switch (key) {
- case "config-id":
- options.teamscaleServer.configId = value;
- return true;
- case CONFIG_FILE_OPTION:
- readConfigFromFile(options, parsePath(filePatternResolver, key, value).toFile());
- return true;
- case LOGGING_CONFIG_OPTION:
- options.loggingConfig = parsePath(filePatternResolver, key, value);
- return true;
- case "interval":
- options.dumpIntervalInMinutes = parseInt(key, value);
- return true;
- case "validate-ssl":
- options.validateSsl = Boolean.parseBoolean(value);
- return true;
- case "out":
- options.setParentOutputDirectory(parsePath(filePatternResolver, key, value));
- return true;
- case "upload-metadata":
- try {
- options.additionalMetaDataFiles = splitMultiOptionValue(value).stream().map(Paths::get).collect(
- Collectors.toList());
- } catch (InvalidPathException e) {
- throw new AgentOptionParseException("Invalid path given for option 'upload-metadata'", e);
- }
- return true;
- case "duplicates":
- options.duplicateClassFileBehavior = parseEnumValue(key, value, EDuplicateClassFileBehavior.class);
- return true;
- case "ignore-uncovered-classes":
- options.ignoreUncoveredClasses = Boolean.parseBoolean(value);
- return true;
- case "obfuscate-security-related-outputs":
- options.obfuscateSecurityRelatedOutputs = Boolean.parseBoolean(value);
- return true;
- case "dump-on-exit":
- options.shouldDumpOnExit = Boolean.parseBoolean(value);
- return true;
- case "search-git-properties-recursively":
- options.searchGitPropertiesRecursively = Boolean.parseBoolean(value);
- return true;
- case ARTIFACTORY_GIT_PROPERTIES_JAR_OPTION:
- logger.warn(
- "The option " + ARTIFACTORY_GIT_PROPERTIES_JAR_OPTION + " is deprecated. It still has an effect, " +
- "but should be replaced with the equivalent option " + AgentOptions.GIT_PROPERTIES_JAR_OPTION + ".");
- // intended fallthrough (acts as alias)
- case AgentOptions.GIT_PROPERTIES_JAR_OPTION:
- options.gitPropertiesJar = getGitPropertiesJarFile(value);
- return true;
- case ARTIFACTORY_GIT_PROPERTIES_COMMIT_DATE_FORMAT_OPTION:
- logger.warn(
- "The option " + ARTIFACTORY_GIT_PROPERTIES_COMMIT_DATE_FORMAT_OPTION + " is deprecated. It still has an effect, " +
- "but should be replaced with the equivalent option " + AgentOptions.GIT_PROPERTIES_COMMIT_DATE_FORMAT_OPTION + ".");
- // intended fallthrough (acts as alias)
- case AgentOptions.GIT_PROPERTIES_COMMIT_DATE_FORMAT_OPTION:
- options.gitPropertiesCommitTimeFormat = DateTimeFormatter.ofPattern(value);
- return true;
- case "mode":
- options.mode = parseEnumValue(key, value, EMode.class);
- return true;
- case "includes":
- options.jacocoIncludes = value.replaceAll(";", ":");
- return true;
- case "excludes":
- options.jacocoExcludes = value.replaceAll(";", ":") + ":" + AgentOptions.DEFAULT_EXCLUDES;
- return true;
- case "class-dir":
- List list = splitMultiOptionValue(value);
- try {
- options.classDirectoriesOrZips = ClasspathUtils.resolveClasspathTextFiles(key, filePatternResolver,
- list);
- } catch (IOException e) {
- throw new AgentOptionParseException(e);
- }
- return true;
- case "http-server-port":
- options.httpServerPort = parseInt(key, value);
- return true;
- case "sap-nwdi-applications":
- options.sapNetWeaverJavaApplications = SapNwdiApplication.parseApplications(value);
- return true;
- case "tia-mode":
- options.testwiseCoverageMode = AgentOptionsParser.parseEnumValue(key, value,
- ETestwiseCoverageMode.class);
- return true;
- default:
- return false;
- }
- }
-
- private void readConfigFromTeamscale(
- AgentOptions options) throws AgentOptionParseException, AgentOptionReceiveException {
- if (options.teamscaleServer.configId == null) {
- return;
- }
- if (!options.teamscaleServer.isConfiguredForServerConnection()) {
- throw new AgentOptionParseException(
- "Config-id '" + options.teamscaleServer.configId + "' specified without teamscale url/user/accessKey! These options must be provided locally via config-file or command line argument.");
- }
- // Set ssl validation option in case it needs to be off before trying to reach Teamscale.
- HttpUtils.setShouldValidateSsl(options.shouldValidateSsl());
- ConfigurationViaTeamscale configuration = ConfigurationViaTeamscale.retrieve(logger,
- options.teamscaleServer.configId,
- options.teamscaleServer.url,
- options.teamscaleServer.userName,
- options.teamscaleServer.userAccessToken);
- options.configurationViaTeamscale = configuration;
- logger.debug(
- "Received the following options from Teamscale: " + configuration.getProfilerConfiguration().configurationOptions);
- readConfigFromString(options, configuration.getProfilerConfiguration().configurationOptions);
- }
-
- private File getGitPropertiesJarFile(String path) {
- File jarFile = new File(path);
- if (!jarFile.exists()) {
- logger.warn(
- "The path provided with the " + AgentOptions.GIT_PROPERTIES_JAR_OPTION + " option does not exist: " + path + ". Continuing without searching it for git.properties files.");
- return null;
- }
- if (!jarFile.isFile()) {
- logger.warn(
- "The path provided with the " + AgentOptions.GIT_PROPERTIES_JAR_OPTION + " option is not a regular file (probably a folder instead): " + path + ". Continuing without searching it for git.properties files.");
- return null;
- }
- return jarFile;
- }
-
- /**
- * Parses the given value as an enum constant case-insensitively and converts "-" to "_".
- */
- public static > T parseEnumValue(String key, String value, Class enumClass)
- throws AgentOptionParseException {
- try {
- return Enum.valueOf(enumClass, value.toUpperCase().replaceAll("-", "_"));
- } catch (IllegalArgumentException e) {
- String validValues = Arrays.stream(enumClass.getEnumConstants()).map(T::name).collect(joining(", "));
- throw new AgentOptionParseException("Invalid value for option `" + key + "`. Valid values: " + validValues,
- e);
- }
- }
-
- /**
- * Reads configuration parameters from the given file. The expected format is basically the same as for the command
- * line, but line breaks are also considered as separators. e.g. class-dir=out # Some comment includes=test.*
- * excludes=third.party.*
- */
- private void readConfigFromFile(AgentOptions options,
- File configFile) throws AgentOptionParseException {
- try {
- String content = FileSystemUtils.readFileUTF8(configFile);
- readConfigFromString(options, content);
- } catch (FileNotFoundException e) {
- throw new AgentOptionParseException(
- "File " + configFile.getAbsolutePath() + " given for option 'config-file' not found", e);
- } catch (IOException e) {
- throw new AgentOptionParseException(
- "An error occurred while reading the config file " + configFile.getAbsolutePath(), e);
- }
- }
-
- private void readConfigFromString(AgentOptions options, String content) {
- List configFileKeyValues = StringUtils.splitLinesAsList(content);
- for (String optionKeyValue : configFileKeyValues) {
- try {
- String trimmedOption = optionKeyValue.trim();
- if (trimmedOption.isEmpty() || trimmedOption.startsWith(COMMENT_PREFIX)) {
- continue;
- }
- handleOptionPart(options, optionKeyValue);
- } catch (Exception e) {
- collectedErrors.add(e);
- }
- }
- }
-
- private int parseInt(String key, String value) throws AgentOptionParseException {
- try {
- return Integer.parseInt(value);
- } catch (NumberFormatException e) {
- throw new AgentOptionParseException("Invalid non-numeric value for option `" + key + "`: " + value);
- }
- }
-
- /**
- * Interprets the given pattern as an Ant pattern and resolves it to one existing {@link Path}. If the given path is
- * relative, it is resolved relative to the current working directory. If more than one file matches the pattern,
- * one of the matching files is used without any guarantees as to which. The selection is, however, guaranteed to be
- * deterministic, i.e. if you run the pattern twice and get the same set of files, the same file will be picked each
- * time.
- */
- public static Path parsePath(FilePatternResolver filePatternResolver, String optionName,
- String pattern) throws AgentOptionParseException {
- try {
- return filePatternResolver.parsePath(optionName, pattern);
- } catch (IOException e) {
- throw new AgentOptionParseException(e);
- }
- }
-
- /**
- * Parses the given value as a URL.
- */
- public static HttpUrl parseUrl(String key, String value) throws AgentOptionParseException {
- if (!value.endsWith("/")) {
- value += "/";
- }
-
- // default to HTTP if no scheme is given and port is not 443, default to HTTPS if no scheme is given AND port is 443
- if (!value.startsWith("http://") && !value.startsWith("https://")) {
- HttpUrl url = getUrl(key, "http://" + value);
- if (url.port() == 443) {
- value = "https://" + value;
- } else {
- value = "http://" + value;
- }
- }
-
- return getUrl(key, value);
- }
-
- private static HttpUrl getUrl(String key, String value) throws AgentOptionParseException {
- HttpUrl url = HttpUrl.parse(value);
- if (url == null) {
- throw new AgentOptionParseException("Invalid URL given for option '" + key + "'");
- }
- return url;
- }
-
- /**
- * Splits the given value at semicolons.
- */
- private static List splitMultiOptionValue(String value) {
- return Arrays.asList(value.split(";"));
- }
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/options/ETestwiseCoverageMode.java b/agent/src/main/java/com/teamscale/jacoco/agent/options/ETestwiseCoverageMode.java
deleted file mode 100644
index d978c2bf2..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/options/ETestwiseCoverageMode.java
+++ /dev/null
@@ -1,15 +0,0 @@
-package com.teamscale.jacoco.agent.options;
-
-import com.teamscale.jacoco.agent.testimpact.TestEventHandlerStrategyBase;
-
-/** Decides which {@link TestEventHandlerStrategyBase} is used in testwise mode. */
-public enum ETestwiseCoverageMode {
- /** Caches testwise coverage in-memory and uploads a report to Teamscale. */
- TEAMSCALE_UPLOAD,
- /** Writes testwise coverage to disk as .json files. */
- DISK,
- /** Writes testwise coverage to disk as .exec files. */
- EXEC_FILE,
- /** Returns testwise coverage to the caller via HTTP. */
- HTTP
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/options/JacocoAgentOptionsBuilder.java b/agent/src/main/java/com/teamscale/jacoco/agent/options/JacocoAgentOptionsBuilder.java
deleted file mode 100644
index 56f2e84e7..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/options/JacocoAgentOptionsBuilder.java
+++ /dev/null
@@ -1,96 +0,0 @@
-package com.teamscale.jacoco.agent.options;
-
-import com.teamscale.jacoco.agent.logging.LoggingUtils;
-import com.teamscale.jacoco.agent.util.AgentUtils;
-import org.slf4j.Logger;
-
-import java.io.IOException;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.util.Collections;
-
-/** Builder for the JaCoCo agent options string. */
-public class JacocoAgentOptionsBuilder {
- private final Logger logger = LoggingUtils.getLogger(this);
-
- private final AgentOptions agentOptions;
-
- public JacocoAgentOptionsBuilder(AgentOptions agentOptions) {
- this.agentOptions = agentOptions;
- }
-
- /**
- * Returns the options to pass to the JaCoCo agent.
- */
- public String createJacocoAgentOptions() throws AgentOptionParseException, IOException {
- StringBuilder builder = new StringBuilder(getModeSpecificOptions());
- if (agentOptions.jacocoIncludes != null) {
- builder.append(",includes=").append(agentOptions.jacocoIncludes);
- }
- if (agentOptions.jacocoExcludes != null) {
- logger.debug("Using default excludes: " + AgentOptions.DEFAULT_EXCLUDES);
- builder.append(",excludes=").append(agentOptions.jacocoExcludes);
- }
-
- // Don't dump class files in testwise mode when coverage is written to an exec file
- boolean needsClassFiles = agentOptions.mode == EMode.NORMAL || agentOptions.testwiseCoverageMode != ETestwiseCoverageMode.EXEC_FILE;
- if (agentOptions.classDirectoriesOrZips.isEmpty() && needsClassFiles) {
- Path tempDir = createTemporaryDumpDirectory();
- tempDir.toFile().deleteOnExit();
- builder.append(",classdumpdir=").append(tempDir.toAbsolutePath());
-
- agentOptions.classDirectoriesOrZips = Collections.singletonList(tempDir.toFile());
- }
-
- agentOptions.additionalJacocoOptions
- .forEach(pair -> builder.append(",").append(pair.getFirst()).append("=").append(pair.getSecond()));
-
- return builder.toString();
- }
-
- private Path createTemporaryDumpDirectory() throws AgentOptionParseException {
- try {
- return Files.createDirectory(AgentUtils.getMainTempDirectory().resolve("jacoco-class-dump"));
- } catch (IOException e) {
- logger.warn("Unable to create temporary directory in default location. Trying in system temp directory.");
- }
-
- try {
- return Files.createTempDirectory("jacoco-class-dump");
- } catch (IOException e) {
- logger.warn("Unable to create temporary directory in default location. Trying in output directory.");
- }
-
- try {
- return Files.createTempDirectory(agentOptions.getOutputDirectory(), "jacoco-class-dump");
- } catch (IOException e) {
- logger.warn("Unable to create temporary directory in output directory. Trying in agent's directory.");
- }
-
- Path agentDirectory = AgentUtils.getAgentDirectory();
- if (agentDirectory == null) {
- throw new AgentOptionParseException("Could not resolve directory that contains the agent");
- }
- try {
- return Files.createTempDirectory(agentDirectory, "jacoco-class-dump");
- } catch (IOException e) {
- throw new AgentOptionParseException("Unable to create a temporary directory anywhere", e);
- }
- }
-
- /**
- * Returns additional options for JaCoCo depending on the selected {@link AgentOptions#mode} and
- * {@link AgentOptions#testwiseCoverageMode}.
- */
- String getModeSpecificOptions() throws IOException {
- if (agentOptions
- .useTestwiseCoverageMode() && agentOptions.testwiseCoverageMode == ETestwiseCoverageMode.EXEC_FILE) {
- // when writing to a .exec file, we can instruct JaCoCo to do so directly
- return "destfile=" + agentOptions.createNewFileInOutputDirectory("jacoco", "exec").getAbsolutePath();
- } else {
- // otherwise we don't need JaCoCo to perform any output of the .exec information
- return "output=none";
- }
- }
-
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/options/ProjectAndCommit.java b/agent/src/main/java/com/teamscale/jacoco/agent/options/ProjectAndCommit.java
deleted file mode 100644
index 58e9d104c..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/options/ProjectAndCommit.java
+++ /dev/null
@@ -1,53 +0,0 @@
-package com.teamscale.jacoco.agent.options;
-
-import com.teamscale.jacoco.agent.commit_resolution.git_properties.CommitInfo;
-
-import java.util.Objects;
-
-/** Class encapsulating the Teamscale project and git commitInfo an upload should be performed to. */
-public class ProjectAndCommit {
-
- private final String project;
- private final CommitInfo commitInfo;
-
- public ProjectAndCommit(String project, CommitInfo commitInfo) {
- this.project = project;
- this.commitInfo = commitInfo;
- }
-
- /** @see #project */
- public String getProject() {
- return project;
- }
-
- /** @see #commitInfo */
- public CommitInfo getCommitInfo() {
- return commitInfo;
- }
-
- @Override
- public boolean equals(Object o) {
- if (this == o) {
- return true;
- }
- if (o == null || getClass() != o.getClass()) {
- return false;
- }
- ProjectAndCommit that = (ProjectAndCommit) o;
- return Objects.equals(project, that.project) &&
- Objects.equals(commitInfo, that.commitInfo);
- }
-
- @Override
- public int hashCode() {
- return Objects.hash(project, commitInfo);
- }
-
- @Override
- public String toString() {
- return "ProjectRevision{" +
- "project='" + project + '\'' +
- ", commitInfo='" + commitInfo + '\'' +
- '}';
- }
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/options/TeamscaleCredentials.java b/agent/src/main/java/com/teamscale/jacoco/agent/options/TeamscaleCredentials.java
deleted file mode 100644
index 254930b0c..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/options/TeamscaleCredentials.java
+++ /dev/null
@@ -1,22 +0,0 @@
-package com.teamscale.jacoco.agent.options;
-
-import okhttp3.HttpUrl;
-
-/** Credentials for accessing a Teamscale instance. */
-public class TeamscaleCredentials {
-
- /** The URL of the Teamscale server. */
- public final HttpUrl url;
-
- /** The user name used to authenticate against Teamscale. */
- public final String userName;
-
- /** The user's access key. */
- public final String accessKey;
-
- public TeamscaleCredentials(HttpUrl url, String userName, String userAccessToken) {
- this.url = url;
- this.userName = userName;
- this.accessKey = userAccessToken;
- }
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/options/TeamscalePropertiesUtils.java b/agent/src/main/java/com/teamscale/jacoco/agent/options/TeamscalePropertiesUtils.java
deleted file mode 100644
index 15de0a912..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/options/TeamscalePropertiesUtils.java
+++ /dev/null
@@ -1,76 +0,0 @@
-package com.teamscale.jacoco.agent.options;
-
-import com.teamscale.client.FileSystemUtils;
-import com.teamscale.jacoco.agent.util.AgentUtils;
-import okhttp3.HttpUrl;
-
-import java.io.IOException;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.util.Properties;
-
-/**
- * Utilities for working with the teamscale.properties file that contains access credentials for the Teamscale
- * instance.
- */
-public class TeamscalePropertiesUtils {
-
- private static final Path TEAMSCALE_PROPERTIES_PATH = AgentUtils.getAgentDirectory()
- .resolve("teamscale.properties");
-
- /**
- * Tries to open {@link #TEAMSCALE_PROPERTIES_PATH} and parse that properties file to obtain
- * {@link TeamscaleCredentials}.
- *
- * @return the parsed credentials or null in case the teamscale.properties file doesn't exist.
- * @throws AgentOptionParseException in case the teamscale.properties file exists but can't be read or parsed.
- */
- public static TeamscaleCredentials parseCredentials() throws AgentOptionParseException {
- return parseCredentials(TEAMSCALE_PROPERTIES_PATH);
- }
-
- /**
- * Same as {@link #parseCredentials()} but testable since the path is not hardcoded.
- */
- /*package*/
- static TeamscaleCredentials parseCredentials(
- Path teamscalePropertiesPath) throws AgentOptionParseException {
- if (!Files.exists(teamscalePropertiesPath)) {
- return null;
- }
-
- try {
- Properties properties = FileSystemUtils.readProperties(teamscalePropertiesPath.toFile());
- return parseProperties(properties);
- } catch (IOException e) {
- throw new AgentOptionParseException("Failed to read " + teamscalePropertiesPath, e);
- }
- }
-
- private static TeamscaleCredentials parseProperties(Properties properties) throws AgentOptionParseException {
- String urlString = properties.getProperty("url");
- if (urlString == null) {
- throw new AgentOptionParseException("teamscale.properties is missing the url field");
- }
-
- HttpUrl url;
- try {
- url = HttpUrl.get(urlString);
- } catch (IllegalArgumentException e) {
- throw new AgentOptionParseException("teamscale.properties contained malformed URL " + urlString, e);
- }
-
- String userName = properties.getProperty("username");
- if (userName == null) {
- throw new AgentOptionParseException("teamscale.properties is missing the username field");
- }
-
- String accessKey = properties.getProperty("accesskey");
- if (accessKey == null) {
- throw new AgentOptionParseException("teamscale.properties is missing the accesskey field");
- }
-
- return new TeamscaleCredentials(url, userName, accessKey);
- }
-
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/options/TeamscaleProxyOptions.java b/agent/src/main/java/com/teamscale/jacoco/agent/options/TeamscaleProxyOptions.java
deleted file mode 100644
index eddbcee2c..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/options/TeamscaleProxyOptions.java
+++ /dev/null
@@ -1,122 +0,0 @@
-package com.teamscale.jacoco.agent.options;
-
-import com.teamscale.client.FileSystemUtils;
-import com.teamscale.client.ProxySystemProperties;
-import com.teamscale.client.StringUtils;
-import com.teamscale.client.TeamscaleProxySystemProperties;
-import com.teamscale.report.util.ILogger;
-
-import java.io.IOException;
-import java.nio.file.Path;
-
-/**
- * Parses agent command line options related to the proxy settings.
- */
-public class TeamscaleProxyOptions {
-
- private final ILogger logger;
-
- /** The host of the proxy server. */
- /* package */ String proxyHost;
-
- /** The port of the proxy server. */
- /* package */ int proxyPort;
-
- /** The password for the proxy user. */
- /* package */ String proxyPassword;
-
- public void setProxyPasswordPath(Path proxyPasswordPath) {
- this.proxyPasswordPath = proxyPasswordPath;
- }
-
- /** A path to the file that contains the password for the proxy authentication. */
- /* package */ Path proxyPasswordPath;
-
- /** The username of the proxy user. */
- /* package */ String proxyUser;
-
- private final ProxySystemProperties.Protocol protocol;
-
- /** Constructor. */
- public TeamscaleProxyOptions(ProxySystemProperties.Protocol protocol, ILogger logger) {
- this.protocol = protocol;
- this.logger = logger;
- ProxySystemProperties proxySystemProperties = new ProxySystemProperties(protocol);
- proxyHost = proxySystemProperties.getProxyHost();
- try {
- proxyPort = proxySystemProperties.getProxyPort();
- } catch (ProxySystemProperties.IncorrectPortFormatException e) {
- proxyPort = -1;
- logger.warn(e.getMessage());
- }
- proxyUser = proxySystemProperties.getProxyUser();
- proxyPassword = proxySystemProperties.getProxyPassword();
- }
-
- /**
- * Processes the command-line options for proxies.
- *
- * @return true if it has successfully processed the given option.
- */
- public boolean handleTeamscaleProxyOptions(String key, String value) throws AgentOptionParseException {
- if ("host".equals(key)) {
- proxyHost = value;
- return true;
- }
- String proxyPortOption = "port";
- if (proxyPortOption.equals(key)) {
- try {
- proxyPort = Integer.parseInt(value);
- } catch (NumberFormatException e) {
- throw new AgentOptionParseException("Could not parse proxy port \"" + value +
- "\" set via \"" + proxyPortOption + "\"", e);
- }
- return true;
- }
- if ("user".equals(key)) {
- proxyUser = value;
- return true;
- } else if ("password".equals(key)) {
- proxyPassword = value;
- return true;
- }
- return false;
- }
-
- /** Stores the teamscale-specific proxy settings as system properties to make them always available. */
- public void putTeamscaleProxyOptionsIntoSystemProperties() {
- TeamscaleProxySystemProperties teamscaleProxySystemProperties = new TeamscaleProxySystemProperties(protocol);
- if (!StringUtils.isEmpty(proxyHost)) {
- teamscaleProxySystemProperties.setProxyHost(proxyHost);
- }
- if (proxyPort > 0) {
- teamscaleProxySystemProperties.setProxyPort(proxyPort);
- }
- if (!StringUtils.isEmpty(proxyUser)) {
- teamscaleProxySystemProperties.setProxyUser(proxyUser);
- }
- if (!StringUtils.isEmpty(proxyPassword)) {
- teamscaleProxySystemProperties.setProxyPassword(proxyPassword);
- }
-
- setProxyPasswordFromFile(proxyPasswordPath);
- }
-
- /**
- * Sets the proxy password JVM property from a file for the protocol in this instance of
- * {@link TeamscaleProxyOptions}.
- */
- private void setProxyPasswordFromFile(Path proxyPasswordFilePath) {
- if (proxyPasswordFilePath == null) {
- return;
- }
- try {
- String proxyPassword = FileSystemUtils.readFileUTF8(proxyPasswordFilePath.toFile()).trim();
- new TeamscaleProxySystemProperties(protocol).setProxyPassword(proxyPassword);
- } catch (IOException e) {
- logger.error(
- "Unable to open file containing proxy password. Please make sure the file exists and the user has the permissions to read the file.",
- e);
- }
- }
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/options/sapnwdi/DelayedSapNwdiMultiUploader.java b/agent/src/main/java/com/teamscale/jacoco/agent/options/sapnwdi/DelayedSapNwdiMultiUploader.java
deleted file mode 100644
index 74b84d6bb..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/options/sapnwdi/DelayedSapNwdiMultiUploader.java
+++ /dev/null
@@ -1,59 +0,0 @@
-package com.teamscale.jacoco.agent.options.sapnwdi;
-
-import com.teamscale.client.CommitDescriptor;
-import com.teamscale.jacoco.agent.upload.DelayedMultiUploaderBase;
-import com.teamscale.jacoco.agent.upload.IUploader;
-
-import java.util.Collection;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.concurrent.Executor;
-import java.util.function.BiFunction;
-
-/**
- * Wraps multiple {@link IUploader}s in order to delay uploads until a {@link CommitDescriptor} is asynchronously made
- * available for each application. Whenever a dump happens the coverage is uploaded to all projects for which a
- * corresponding commit has already been found. Uploads for application that have not commit at that time are skipped.
- *
- * This is safe assuming that the marker class is the central entry point for the application and therefore there should
- * not be any relevant coverage for the application as long as the marker class has not been loaded.
- */
-public class DelayedSapNwdiMultiUploader extends DelayedMultiUploaderBase implements IUploader {
-
- private final BiFunction uploaderFactory;
-
- /** The wrapped uploader instances. */
- private final Map uploaders = new HashMap<>();
-
- /**
- * Visible for testing. Allows tests to control the {@link Executor} to test the asynchronous functionality of this
- * class.
- */
- public DelayedSapNwdiMultiUploader(
- BiFunction uploaderFactory) {
- this.uploaderFactory = uploaderFactory;
- registerShutdownHook();
- }
-
- /** Registers the shutdown hook. */
- private void registerShutdownHook() {
- Runtime.getRuntime().addShutdownHook(new Thread(() -> {
- if (getWrappedUploaders().isEmpty()) {
- logger.error("The application was shut down before a commit could be found. The recorded coverage" +
- " is lost.");
- }
- }));
- }
-
- /** Sets the commit info detected for the application. */
- public void setCommitForApplication(CommitDescriptor commit, SapNwdiApplication application) {
- logger.info("Found commit for " + application.markerClass + ": " + commit);
- IUploader uploader = uploaderFactory.apply(commit, application);
- uploaders.put(application, uploader);
- }
-
- @Override
- protected Collection getWrappedUploaders() {
- return uploaders.values();
- }
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/options/sapnwdi/SapNwdiApplication.java b/agent/src/main/java/com/teamscale/jacoco/agent/options/sapnwdi/SapNwdiApplication.java
deleted file mode 100644
index 5c3e85df5..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/options/sapnwdi/SapNwdiApplication.java
+++ /dev/null
@@ -1,83 +0,0 @@
-package com.teamscale.jacoco.agent.options.sapnwdi;
-
-import com.teamscale.jacoco.agent.options.AgentOptionParseException;
-
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Objects;
-
-/**
- * An SAP application that is identified by a {@link #markerClass} and refers to a corresponding Teamscale project.
- */
-public class SapNwdiApplication {
-
- /** Parses an application definition string e.g. "com.package.MyClass:projectId;com.company.Main:project". */
- public static List parseApplications(String applications) throws AgentOptionParseException {
- List nwdiConfiguration = new ArrayList<>();
- String[] markerClassAndProjectPairs = applications.split(";");
- if (markerClassAndProjectPairs.length == 0) {
- throw new AgentOptionParseException("Application definition is expected not to be empty.");
- }
-
- for (String markerClassAndProjectPair : markerClassAndProjectPairs) {
- if (markerClassAndProjectPair.trim().isEmpty()) {
- throw new AgentOptionParseException("Application definition is expected not to be empty.");
- }
- String[] markerClassAndProject = markerClassAndProjectPair.split(":");
- if (markerClassAndProject.length != 2) {
- throw new AgentOptionParseException(
- "Application definition " + markerClassAndProjectPair + " is expected to contain a marker class and project separated by a colon.");
- }
- String markerClass = markerClassAndProject[0].trim();
- if (markerClass.isEmpty()) {
- throw new AgentOptionParseException("Marker class is not given for " + markerClassAndProjectPair + "!");
- }
- String teamscaleProject = markerClassAndProject[1].trim();
- if (teamscaleProject.isEmpty()) {
- throw new AgentOptionParseException(
- "Teamscale project is not given for " + markerClassAndProjectPair + "!");
- }
- SapNwdiApplication nwdiApplication = new SapNwdiApplication(markerClass, teamscaleProject);
- nwdiConfiguration.add(nwdiApplication);
- }
- return nwdiConfiguration;
- }
-
- /** A fully qualified class name that is used to match a jar file to this application. */
- public final String markerClass;
-
- /** The teamscale project to which coverage should be uploaded. */
- public final String teamscaleProject;
-
- private SapNwdiApplication(String markerClass, String teamscaleProject) {
- this.markerClass = markerClass;
- this.teamscaleProject = teamscaleProject;
- }
-
- /** @see #markerClass */
- public String getMarkerClass() {
- return markerClass;
- }
-
- /** @see #teamscaleProject */
- public String getTeamscaleProject() {
- return teamscaleProject;
- }
-
- @Override
- public boolean equals(Object o) {
- if (this == o) {
- return true;
- }
- if (o == null || getClass() != o.getClass()) {
- return false;
- }
- SapNwdiApplication that = (SapNwdiApplication) o;
- return markerClass.equals(that.markerClass) && teamscaleProject.equals(that.teamscaleProject);
- }
-
- @Override
- public int hashCode() {
- return Objects.hash(markerClass, teamscaleProject);
- }
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/CoverageToDiskStrategy.java b/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/CoverageToDiskStrategy.java
deleted file mode 100644
index 9dc8b0c01..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/CoverageToDiskStrategy.java
+++ /dev/null
@@ -1,26 +0,0 @@
-package com.teamscale.jacoco.agent.testimpact;
-
-import com.teamscale.client.FileSystemUtils;
-import com.teamscale.jacoco.agent.JacocoRuntimeController;
-import com.teamscale.jacoco.agent.options.AgentOptions;
-import com.teamscale.report.testwise.jacoco.JaCoCoTestwiseReportGenerator;
-
-import java.io.File;
-import java.io.IOException;
-
-/**
- * Strategy for appending coverage into one json test-wise coverage file with one session per test.
- */
-public class CoverageToDiskStrategy extends CoverageToJsonStrategyBase {
-
- public CoverageToDiskStrategy(JacocoRuntimeController controller, AgentOptions agentOptions,
- JaCoCoTestwiseReportGenerator reportGenerator) {
- super(controller, agentOptions, reportGenerator);
- }
-
- @Override
- protected void handleTestwiseCoverageJsonReady(String json) throws IOException {
- File reportFile = agentOptions.createNewFileInPartitionOutputDirectory("testwise-coverage", "json");
- FileSystemUtils.writeFileUTF8(reportFile, json);
- }
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/CoverageToExecFileStrategy.java b/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/CoverageToExecFileStrategy.java
deleted file mode 100644
index 4a61870ef..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/CoverageToExecFileStrategy.java
+++ /dev/null
@@ -1,49 +0,0 @@
-package com.teamscale.jacoco.agent.testimpact;
-
-import com.teamscale.jacoco.agent.JacocoRuntimeController;
-import com.teamscale.jacoco.agent.logging.LoggingUtils;
-import com.teamscale.jacoco.agent.options.AgentOptions;
-import com.teamscale.report.testwise.jacoco.cache.CoverageGenerationException;
-import com.teamscale.report.testwise.model.TestExecution;
-import com.teamscale.report.testwise.model.TestInfo;
-import org.slf4j.Logger;
-
-import java.io.IOException;
-
-/**
- * Strategy for appending coverage into one exec file with one session per test. Execution data will be stored in a json
- * file side-by-side with the exec file. Test executions are also appended into a single file.
- */
-public class CoverageToExecFileStrategy extends TestEventHandlerStrategyBase {
-
- private final Logger logger = LoggingUtils.getLogger(this);
-
- /** Helper for writing test executions to disk. */
- private final TestExecutionWriter testExecutionWriter;
-
- public CoverageToExecFileStrategy(JacocoRuntimeController controller, AgentOptions agentOptions,
- TestExecutionWriter testExecutionWriter) {
- super(agentOptions, controller);
- this.testExecutionWriter = testExecutionWriter;
- }
-
- @Override
- public TestInfo testEnd(String test,
- TestExecution testExecution) throws JacocoRuntimeController.DumpException, CoverageGenerationException {
- logger.debug("Test {} ended with execution {}. Writing exec file and test execution", test, testExecution);
- super.testEnd(test, testExecution);
- controller.dump();
- // Ensures that the coverage collected between the last test and the JVM shutdown
- // is not considered a test with the same name as the last test
- controller.resetSessionId();
- if (testExecution != null) {
- try {
- testExecutionWriter.append(testExecution);
- logger.debug("Successfully wrote test execution for {}", test);
- } catch (IOException e) {
- logger.error("Failed to store test execution: {}", e.getMessage(), e);
- }
- }
- return null;
- }
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/CoverageToJsonStrategyBase.java b/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/CoverageToJsonStrategyBase.java
deleted file mode 100644
index 68ffe481e..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/CoverageToJsonStrategyBase.java
+++ /dev/null
@@ -1,152 +0,0 @@
-package com.teamscale.jacoco.agent.testimpact;
-
-import com.teamscale.client.ClusteredTestDetails;
-import com.teamscale.client.JsonUtils;
-import com.teamscale.client.PrioritizableTestCluster;
-import com.teamscale.jacoco.agent.JacocoRuntimeController;
-import com.teamscale.jacoco.agent.logging.LoggingUtils;
-import com.teamscale.jacoco.agent.options.AgentOptions;
-import com.teamscale.report.testwise.jacoco.JaCoCoTestwiseReportGenerator;
-import com.teamscale.report.testwise.jacoco.cache.CoverageGenerationException;
-import com.teamscale.report.testwise.model.TestExecution;
-import com.teamscale.report.testwise.model.TestInfo;
-import com.teamscale.report.testwise.model.TestwiseCoverage;
-import com.teamscale.report.testwise.model.TestwiseCoverageReport;
-import com.teamscale.report.testwise.model.builder.TestCoverageBuilder;
-import com.teamscale.report.testwise.model.builder.TestwiseCoverageReportBuilder;
-import org.slf4j.Logger;
-
-import java.io.File;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.List;
-
-import static java.util.stream.Collectors.toList;
-
-/**
- * Base for strategies that produce testwise coverage information in JSON and store or send this data further.
- */
-public abstract class CoverageToJsonStrategyBase extends TestEventHandlerStrategyBase {
-
- /**
- * The logger to use.
- */
- protected final Logger logger = LoggingUtils.getLogger(this);
-
- /**
- * The path to the exec file into which the coverage of the current test run is appended to. Will be null if there
- * is no file for the current test run yet.
- */
- private File testExecFile;
- private final List testExecutions = new ArrayList<>();
- private List availableTests = new ArrayList<>();
-
- private final JaCoCoTestwiseReportGenerator reportGenerator;
-
- public CoverageToJsonStrategyBase(JacocoRuntimeController controller, AgentOptions agentOptions,
- JaCoCoTestwiseReportGenerator reportGenerator) {
- super(agentOptions, controller);
- this.reportGenerator = reportGenerator;
- }
-
- @Override
- public List testRunStart(List availableTests,
- boolean includeNonImpactedTests,
- boolean includeAddedTests, boolean includeFailedAndSkipped,
- String baseline, String baselineRevision) throws IOException {
- if (availableTests != null) {
- this.availableTests = new ArrayList<>(availableTests);
- }
- return super.testRunStart(this.availableTests, includeNonImpactedTests, includeAddedTests,
- includeFailedAndSkipped, baseline, baselineRevision);
- }
-
- @Override
- public void testStart(String uniformPath) {
- super.testStart(uniformPath);
- if (availableTests.stream().noneMatch(test -> test.uniformPath.equals(uniformPath))) {
- // ensure that we can at least generate a report for the tests that were actually run,
- // even if the caller did not provide a list of tests up-front in testRunStart
- availableTests.add(new ClusteredTestDetails(uniformPath, uniformPath, null, null));
- }
- }
-
- @Override
- public TestInfo testEnd(String test,
- TestExecution testExecution) throws JacocoRuntimeController.DumpException, CoverageGenerationException {
- super.testEnd(test, testExecution);
- if (testExecution != null) {
- testExecutions.add(testExecution);
- }
-
- try {
- if (testExecFile == null) {
- testExecFile = agentOptions.createNewFileInOutputDirectory("coverage", "exec");
- testExecFile.deleteOnExit();
- }
- controller.dumpToFileAndReset(testExecFile);
- } catch (IOException e) {
- throw new JacocoRuntimeController.DumpException(
- "Failed to write coverage to disk into " + testExecFile + "!",
- e);
- }
-
- return null;
- }
-
- @Override
- public void testRunEnd(boolean partial) throws IOException, CoverageGenerationException {
- if (testExecFile == null) {
- logger.warn("Tried to end a test run that contained no tests!");
- clearTestRun();
- return;
- }
-
- String testwiseCoverageJson = createTestwiseCoverageReport(partial);
- handleTestwiseCoverageJsonReady(testwiseCoverageJson);
- }
-
- /**
- * Hook that is invoked when the JSON is ready for processed further.
- */
- protected abstract void handleTestwiseCoverageJsonReady(String json) throws IOException;
-
- /**
- * Creates a testwise coverage report from the coverage collected in {@link #testExecFile} and the test execution
- * information in {@link #testExecutions}.
- */
- private String createTestwiseCoverageReport(boolean partial) throws IOException, CoverageGenerationException {
- List executionUniformPaths = testExecutions.stream().map(execution -> {
- if (execution == null) {
- return null;
- } else {
- return execution.uniformPath;
- }
- }).collect(toList());
-
- logger.debug(
- "Creating testwise coverage from available tests `{}`, test executions `{}`, exec file and partial {}",
- availableTests.stream().map(test -> test.uniformPath).collect(toList()),
- executionUniformPaths, partial);
- reportGenerator.updateClassDirCache();
- TestwiseCoverage testwiseCoverage = reportGenerator.convert(testExecFile);
- logger.debug("Created testwise coverage report (containing coverage for tests `{}`)",
- testwiseCoverage.getTests().values().stream().map(TestCoverageBuilder::getUniformPath)
- .collect(toList()));
-
- TestwiseCoverageReport report = TestwiseCoverageReportBuilder.createFrom(availableTests,
- testwiseCoverage.getTests().values(), testExecutions, partial);
-
- testExecFile.delete();
- testExecFile = null;
- clearTestRun();
-
- return JsonUtils.serializeToJson(report);
- }
-
- private void clearTestRun() {
- availableTests.clear();
- testExecutions.clear();
- }
-
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/CoverageToTeamscaleStrategy.java b/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/CoverageToTeamscaleStrategy.java
deleted file mode 100644
index 2c2641ff5..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/CoverageToTeamscaleStrategy.java
+++ /dev/null
@@ -1,43 +0,0 @@
-package com.teamscale.jacoco.agent.testimpact;
-
-import com.teamscale.client.EReportFormat;
-import com.teamscale.client.FileSystemUtils;
-import com.teamscale.jacoco.agent.JacocoRuntimeController;
-import com.teamscale.jacoco.agent.options.AgentOptions;
-import com.teamscale.report.testwise.jacoco.JaCoCoTestwiseReportGenerator;
-
-import java.io.File;
-import java.io.IOException;
-import java.util.List;
-
-/**
- * Strategy that records test-wise coverage and uploads the resulting report to Teamscale. Also handles the
- * {@link #testRunStart(List, boolean, boolean, boolean, String, String)} event by retrieving tests to run from
- * Teamscale.
- */
-public class CoverageToTeamscaleStrategy extends CoverageToJsonStrategyBase {
-
- public CoverageToTeamscaleStrategy(JacocoRuntimeController controller, AgentOptions agentOptions,
- JaCoCoTestwiseReportGenerator reportGenerator) {
- super(controller, agentOptions, reportGenerator);
- }
-
- @Override
- protected void handleTestwiseCoverageJsonReady(String json) throws IOException {
- try {
- teamscaleClient
- .uploadReport(EReportFormat.TESTWISE_COVERAGE, json,
- agentOptions.getTeamscaleServerOptions().commit,
- agentOptions.getTeamscaleServerOptions().revision,
- agentOptions.getTeamscaleServerOptions().repository,
- agentOptions.getTeamscaleServerOptions().partition,
- agentOptions.getTeamscaleServerOptions().getMessage());
- } catch (IOException e) {
- File reportFile = agentOptions.createNewFileInOutputDirectory("testwise-coverage", "json");
- FileSystemUtils.writeFileUTF8(reportFile, json);
- String errorMessage = "Failed to upload coverage to Teamscale! Report is stored in " + reportFile + "!";
- logger.error(errorMessage, e);
- throw new IOException(errorMessage, e);
- }
- }
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/CoverageViaHttpStrategy.java b/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/CoverageViaHttpStrategy.java
deleted file mode 100644
index 840e40128..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/CoverageViaHttpStrategy.java
+++ /dev/null
@@ -1,49 +0,0 @@
-package com.teamscale.jacoco.agent.testimpact;
-
-import com.teamscale.jacoco.agent.JacocoRuntimeController;
-import com.teamscale.jacoco.agent.options.AgentOptions;
-import com.teamscale.jacoco.agent.logging.LoggingUtils;
-import com.teamscale.report.jacoco.dump.Dump;
-import com.teamscale.report.testwise.jacoco.JaCoCoTestwiseReportGenerator;
-import com.teamscale.report.testwise.jacoco.cache.CoverageGenerationException;
-import com.teamscale.report.testwise.model.TestExecution;
-import com.teamscale.report.testwise.model.TestInfo;
-import com.teamscale.report.testwise.model.builder.TestInfoBuilder;
-import org.slf4j.Logger;
-
-import java.util.Objects;
-
-/**
- * Strategy which directly converts the collected coverage into a JSON object in place and returns the result to the
- * caller as response to the http request. If a test execution is given it is merged into the representation and
- * returned together with the coverage.
- */
-public class CoverageViaHttpStrategy extends TestEventHandlerStrategyBase {
-
- private final Logger logger = LoggingUtils.getLogger(this);
-
- private final JaCoCoTestwiseReportGenerator reportGenerator;
-
- public CoverageViaHttpStrategy(JacocoRuntimeController controller, AgentOptions agentOptions,
- JaCoCoTestwiseReportGenerator reportGenerator) {
- super(agentOptions, controller);
- this.reportGenerator = reportGenerator;
- }
-
- @Override
- public TestInfo testEnd(String test, TestExecution testExecution)
- throws JacocoRuntimeController.DumpException, CoverageGenerationException {
- super.testEnd(test, testExecution);
-
- TestInfoBuilder builder = new TestInfoBuilder(test);
- Dump dump = controller.dumpAndReset();
- reportGenerator.updateClassDirCache();
- builder.setCoverage(Objects.requireNonNull(reportGenerator.convert(dump)));
- if (testExecution != null) {
- builder.setExecution(testExecution);
- }
- TestInfo testInfo = builder.build();
- logger.debug("Generated test info {}", testInfo);
- return testInfo;
- }
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/TestEventHandlerStrategyBase.java b/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/TestEventHandlerStrategyBase.java
deleted file mode 100644
index 0bd0aa1c4..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/TestEventHandlerStrategyBase.java
+++ /dev/null
@@ -1,162 +0,0 @@
-package com.teamscale.jacoco.agent.testimpact;
-
-import com.teamscale.client.ClusteredTestDetails;
-import com.teamscale.client.HttpUtils;
-import com.teamscale.client.PrioritizableTestCluster;
-import com.teamscale.client.TeamscaleClient;
-import com.teamscale.client.TestWithClusterId;
-import com.teamscale.jacoco.agent.JacocoRuntimeController;
-import com.teamscale.jacoco.agent.logging.LoggingUtils;
-import com.teamscale.jacoco.agent.options.AgentOptions;
-import com.teamscale.jacoco.agent.upload.teamscale.TeamscaleConfig;
-import com.teamscale.report.testwise.jacoco.cache.CoverageGenerationException;
-import com.teamscale.report.testwise.model.TestExecution;
-import com.teamscale.report.testwise.model.TestInfo;
-import org.slf4j.Logger;
-import retrofit2.Response;
-
-import java.io.IOException;
-import java.util.Collections;
-import java.util.List;
-import java.util.stream.Collectors;
-
-/** Base class for strategies to handle test events. */
-public abstract class TestEventHandlerStrategyBase {
-
- private final Logger logger = LoggingUtils.getLogger(this);
-
- /** Controls the JaCoCo runtime. */
- protected final JacocoRuntimeController controller;
-
- /** The timestamp at which the /test/start endpoint has been called last time. */
- private long startTimestamp = -1;
-
- /** The options the user has configured for the agent. */
- protected final AgentOptions agentOptions;
-
- /** May be null if the user did not configure Teamscale. */
- protected final TeamscaleClient teamscaleClient;
-
-
- protected TestEventHandlerStrategyBase(AgentOptions agentOptions, JacocoRuntimeController controller) {
- this.controller = controller;
- this.agentOptions = agentOptions;
- this.teamscaleClient = agentOptions.createTeamscaleClient(true);
- }
-
- /** Called when test test with the given name is about to start. */
- public void testStart(String test) {
- logger.debug("Test {} started", test);
- // Reset coverage so that we only record coverage that belongs to this particular test case.
- controller.reset();
- controller.setSessionId(test);
- startTimestamp = System.currentTimeMillis();
- }
-
- /**
- * Called when the test with the given name finished.
- *
- * @param test Uniform path of the test
- * @param testExecution A test execution object holding the test result and error message. May be null if none is
- * given in the request.
- * @return The body of the response. null indicates "204 No content". Non-null results will be treated
- * as a json response.
- */
- public TestInfo testEnd(String test,
- TestExecution testExecution) throws JacocoRuntimeController.DumpException, CoverageGenerationException {
- if (testExecution != null) {
- testExecution.uniformPath = test;
- if (startTimestamp != -1) {
- long endTimestamp = System.currentTimeMillis();
- testExecution.setDurationMillis(endTimestamp - startTimestamp);
- }
- }
- logger.debug("Test {} ended with test execution {}", test, testExecution);
- return null;
- }
-
- /**
- * Retrieves impacted tests from Teamscale, if a {@link #teamscaleClient} has been configured.
- *
- * @param availableTests List of all available tests that could be run or null if the user does not want to
- * provide one.
- * @param includeNonImpactedTests If this is true, only performs prioritization, no selection.
- * @param baseline Optional baseline for the considered changes.
- * @throws IOException if the request to Teamscale failed.
- * @throws UnsupportedOperationException if the user did not properly configure the {@link #teamscaleClient}.
- */
- public List testRunStart(List availableTests,
- boolean includeNonImpactedTests,
- boolean includeAddedTests, boolean includeFailedAndSkipped,
- String baseline, String baselineRevision) throws IOException {
- int availableTestCount = 0;
- List availableTestsWithClusterId = null;
- if (availableTests != null) {
- availableTestCount = availableTests.size();
- availableTestsWithClusterId = availableTests.stream()
- .map(availableTest -> TestWithClusterId.Companion.fromClusteredTestDetails(availableTest, getPartition()))
- .collect(
- Collectors.toList());
- }
- logger.debug("Test run started with {} available tests. baseline = {}, includeNonImpactedTests = {}",
- availableTestCount, baseline, includeNonImpactedTests);
- validateConfiguration();
-
- Response> response = teamscaleClient
- .getImpactedTests(availableTestsWithClusterId, baseline, baselineRevision,
- agentOptions.getTeamscaleServerOptions().commit,
- agentOptions.getTeamscaleServerOptions().revision,
- agentOptions.getTeamscaleServerOptions().repository,
- Collections.singletonList(agentOptions.getTeamscaleServerOptions().partition),
- includeNonImpactedTests, includeAddedTests, includeFailedAndSkipped);
- if (response.isSuccessful()) {
- List prioritizableTestClusters = response.body();
- logger.debug("Teamscale suggested these tests: {}", prioritizableTestClusters);
- return prioritizableTestClusters;
- } else {
- String responseBody = HttpUtils.getErrorBodyStringSafe(response);
- throw new IOException(
- "Request to Teamscale to get impacted tests failed with HTTP status " + response.code() +
- " " + response.message() + ". Response body: " + responseBody);
- }
- }
-
- /**
- * Returns the partition defined in the agent options. Asserts that the partition is defined.
- */
- private String getPartition() {
- String partition = agentOptions.getTeamscaleServerOptions().partition;
- if (partition == null) {
- throw new UnsupportedOperationException(
- "You must provide a partition via the agent's '" + TeamscaleConfig.TEAMSCALE_PARTITION_OPTION + "' option or using the /partition REST endpoint.");
- }
- return partition;
- }
-
- private void validateConfiguration() {
- if (teamscaleClient == null) {
- throw new UnsupportedOperationException("You did not configure a connection to Teamscale in the agent." +
- " Thus, you cannot use the agent to retrieve impacted tests via the testrun/start REST endpoint." +
- " Please use the 'teamscale-' agent parameters to configure a Teamscale connection.");
- }
- if (!agentOptions.getTeamscaleServerOptions().hasCommitOrRevision()) {
- throw new UnsupportedOperationException(
- "You must provide a revision or commit via the agent's '" + TeamscaleConfig.TEAMSCALE_REVISION_OPTION + "', '" +
- TeamscaleConfig.TEAMSCALE_REVISION_MANIFEST_JAR_OPTION + "', '" + TeamscaleConfig.TEAMSCALE_COMMIT_OPTION +
- "', '" + TeamscaleConfig.TEAMSCALE_COMMIT_MANIFEST_JAR_OPTION + "' or '" +
- AgentOptions.GIT_PROPERTIES_JAR_OPTION + "' option." +
- " Auto-detecting the git.properties does not work since we need the commit before any code" +
- " has been profiled in order to obtain the prioritized test cases from the TIA.");
- }
- }
-
- /**
- * Signals that the test run has ended. Strategies that support this can upload a report via the
- * {@link #teamscaleClient} here.
- */
- public void testRunEnd(boolean partial) throws IOException, CoverageGenerationException {
- throw new UnsupportedOperationException("You configured the agent in a mode that does not support uploading " +
- "reports to Teamscale. Please configure 'tia-mode=teamscale-upload' or simply don't call" +
- "POST /testrun/end.");
- }
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/TestExecutionWriter.java b/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/TestExecutionWriter.java
deleted file mode 100644
index e62261cda..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/TestExecutionWriter.java
+++ /dev/null
@@ -1,53 +0,0 @@
-package com.teamscale.jacoco.agent.testimpact;
-
-import com.teamscale.client.JsonUtils;
-import com.teamscale.jacoco.agent.logging.LoggingUtils;
-import com.teamscale.report.testwise.model.TestExecution;
-import org.slf4j.Logger;
-
-import java.io.File;
-import java.io.IOException;
-import java.io.RandomAccessFile;
-import java.nio.charset.StandardCharsets;
-
-/**
- * Helper class for writing a list of test executions to a file. This class ensures that we never have to hold all test
- * executions in memory but rather incrementally append to the output file. This ensures that we don't use unnecessary
- * amounts of memory during profiling.
- */
-public class TestExecutionWriter {
-
- private final Logger logger = LoggingUtils.getLogger(this);
-
- private final File testExecutionFile;
- private boolean hasWrittenAtLeastOneExecution = false;
-
- public TestExecutionWriter(File testExecutionFile) {
- this.testExecutionFile = testExecutionFile;
- logger.debug("Writing test executions to {}", testExecutionFile);
- }
-
- /** Appends the given {@link TestExecution} to the test execution list file. */
- public synchronized void append(TestExecution testExecution) throws IOException {
- String json = JsonUtils.serializeToJson(testExecution);
-
- // the file contains a JSON array if it exists and to append to it, we strip the trailing "]" and append
- // our new entry and a closing "]"
- // "rwd" means open for read-write and flush all changes directly to disk
- try (RandomAccessFile file = new RandomAccessFile(testExecutionFile, "rwd")) {
- String textToWrite = json + "]";
- if (hasWrittenAtLeastOneExecution) {
- textToWrite = "," + textToWrite;
- // overwrite the trailing "]"
- file.seek(file.length() - 1);
- } else {
- textToWrite = "[" + textToWrite;
- }
-
- file.write(textToWrite.getBytes(StandardCharsets.UTF_8));
- }
-
- hasWrittenAtLeastOneExecution = true;
- }
-
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/TestwiseCoverageAgent.java b/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/TestwiseCoverageAgent.java
deleted file mode 100644
index 8effbdfb6..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/TestwiseCoverageAgent.java
+++ /dev/null
@@ -1,75 +0,0 @@
-/*-------------------------------------------------------------------------+
-| |
-| Copyright (c) 2009-2018 CQSE GmbH |
-| |
-+-------------------------------------------------------------------------*/
-package com.teamscale.jacoco.agent.testimpact;
-
-import com.teamscale.jacoco.agent.AgentBase;
-import com.teamscale.jacoco.agent.GenericExceptionMapper;
-import com.teamscale.jacoco.agent.logging.LoggingUtils;
-import com.teamscale.jacoco.agent.options.AgentOptions;
-import com.teamscale.report.testwise.jacoco.JaCoCoTestwiseReportGenerator;
-import org.glassfish.jersey.server.ResourceConfig;
-import org.glassfish.jersey.server.ServerProperties;
-import org.slf4j.Logger;
-
-import java.io.IOException;
-
-/**
- * A wrapper around the JaCoCo Java agent that starts a HTTP server and listens for test events.
- */
-public class TestwiseCoverageAgent extends AgentBase {
-
- /**
- * The test event strategy handler.
- */
- protected final TestEventHandlerStrategyBase testEventHandler;
-
- /** Creates a {@link TestwiseCoverageAgent} based on the given options. */
- public static TestwiseCoverageAgent create(AgentOptions agentOptions) throws IOException {
- Logger logger = LoggingUtils.getLogger(JaCoCoTestwiseReportGenerator.class);
- JaCoCoTestwiseReportGenerator reportGenerator = new JaCoCoTestwiseReportGenerator(
- agentOptions.getClassDirectoriesOrZips(), agentOptions.getLocationIncludeFilter(),
- agentOptions.getDuplicateClassFileBehavior(), LoggingUtils.wrap(logger));
- return new TestwiseCoverageAgent(agentOptions,
- new TestExecutionWriter(agentOptions.createNewFileInOutputDirectory("test-execution", "json")),
- reportGenerator);
- }
-
-
- public TestwiseCoverageAgent(AgentOptions options, TestExecutionWriter testExecutionWriter,
- JaCoCoTestwiseReportGenerator reportGenerator) throws IllegalStateException {
- super(options);
- switch (options.getTestwiseCoverageMode()) {
- case TEAMSCALE_UPLOAD:
- testEventHandler = new CoverageToTeamscaleStrategy(controller, options, reportGenerator);
- break;
- case DISK:
- testEventHandler = new CoverageToDiskStrategy(controller, options, reportGenerator);
- break;
- case HTTP:
- testEventHandler = new CoverageViaHttpStrategy(controller, options, reportGenerator);
- break;
- default:
- testEventHandler = new CoverageToExecFileStrategy(controller, options, testExecutionWriter);
- break;
- }
- // Set to empty to not end up with a default session in case no tests are executed,
- // which in turn causes a warning because we didn't write a corresponding test detail
- controller.setSessionId("");
- }
-
- @Override
- protected ResourceConfig initResourceConfig() {
- ResourceConfig resourceConfig = new ResourceConfig();
- resourceConfig.property(ServerProperties.WADL_FEATURE_DISABLE, Boolean.TRUE.toString());
- TestwiseCoverageResource.setAgent(this);
- return resourceConfig.register(TestwiseCoverageResource.class).register(GenericExceptionMapper.class);
- }
-
- @Override
- public void dumpReport() {
- // Dumping via the API is not supported in testwise mode. Ending the test run dumps automatically
- }
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/TestwiseCoverageResource.java b/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/TestwiseCoverageResource.java
deleted file mode 100644
index 6630be4e4..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/TestwiseCoverageResource.java
+++ /dev/null
@@ -1,106 +0,0 @@
-package com.teamscale.jacoco.agent.testimpact;
-
-import com.teamscale.client.ClusteredTestDetails;
-import com.teamscale.client.PrioritizableTestCluster;
-import com.teamscale.jacoco.agent.JacocoRuntimeController;
-import com.teamscale.jacoco.agent.ResourceBase;
-import com.teamscale.report.testwise.jacoco.cache.CoverageGenerationException;
-import com.teamscale.report.testwise.model.TestExecution;
-import com.teamscale.report.testwise.model.TestInfo;
-
-import javax.ws.rs.DefaultValue;
-import javax.ws.rs.GET;
-import javax.ws.rs.POST;
-import javax.ws.rs.Path;
-import javax.ws.rs.PathParam;
-import javax.ws.rs.Produces;
-import javax.ws.rs.QueryParam;
-import javax.ws.rs.core.MediaType;
-import javax.ws.rs.core.Response;
-import java.io.IOException;
-import java.util.List;
-
-/**
- * The resource of the Jersey + Jetty http server holding all the endpoints specific for the
- * {@link TestwiseCoverageAgent}.
- */
-@Path("/")
-public class TestwiseCoverageResource extends ResourceBase {
-
- /** Path parameter placeholder used in the HTTP requests. */
- private static final String TEST_ID_PARAMETER = "testId";
-
- private static TestwiseCoverageAgent testwiseCoverageAgent;
-
- /**
- * Static setter to inject the {@link TestwiseCoverageAgent} to the resource.
- */
- public static void setAgent(TestwiseCoverageAgent agent) {
- TestwiseCoverageResource.testwiseCoverageAgent = agent;
- ResourceBase.agentBase = agent;
- }
-
- /** Returns the session ID of the current test. */
- @GET
- @Path("/test")
- public String getTest() {
- return testwiseCoverageAgent.controller.getSessionId();
- }
-
-
- /** Handles the start of a new test case by setting the session ID. */
- @POST
- @Path("/test/start/{" + TEST_ID_PARAMETER + "}")
- public Response handleTestStart(@PathParam(TEST_ID_PARAMETER) String testId) {
- if (testId == null || testId.isEmpty()) {
- handleBadRequest("Test name is missing!");
- }
-
- logger.debug("Start test {}", testId);
-
- testwiseCoverageAgent.testEventHandler.testStart(testId);
- return Response.noContent().build();
- }
-
- /** Handles the end of a test case by resetting the session ID. */
- @POST
- @Produces(MediaType.APPLICATION_JSON)
- @Path("/test/end/{" + TEST_ID_PARAMETER + "}")
- public TestInfo handleTestEnd(@PathParam(TEST_ID_PARAMETER) String testId,
- TestExecution testExecution) throws JacocoRuntimeController.DumpException, CoverageGenerationException {
- if (testId == null || testId.isEmpty()) {
- handleBadRequest("Test name is missing!");
- }
-
- logger.debug("End test {}", testId);
-
- return testwiseCoverageAgent.testEventHandler.testEnd(testId,
- testExecution);
- }
-
- /** Handles the start of a new testrun. */
- @POST
- @Produces(MediaType.APPLICATION_JSON)
- @Path("/testrun/start")
- public List handleTestRunStart(
- @QueryParam("include-non-impacted") boolean includeNonImpactedTests,
- @QueryParam("include-added-tests") boolean includeAddedTests,
- @QueryParam("include-failed-and-skipped") boolean includeFailedAndSkipped,
- @QueryParam("baseline") String baseline,
- @QueryParam("baseline-revision") String baselineRevision,
- List availableTests) throws IOException {
-
- return testwiseCoverageAgent.testEventHandler.testRunStart(availableTests,
- includeNonImpactedTests, includeAddedTests,
- includeFailedAndSkipped, baseline, baselineRevision);
- }
-
- /** Handles the end of a new testrun. */
- @POST
- @Path("/testrun/end")
- public Response handleTestRunEnd(
- @DefaultValue("false") @QueryParam("partial") boolean partial) throws IOException, CoverageGenerationException {
- testwiseCoverageAgent.testEventHandler.testRunEnd(partial);
- return Response.noContent().build();
- }
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/upload/DelayedMultiUploaderBase.java b/agent/src/main/java/com/teamscale/jacoco/agent/upload/DelayedMultiUploaderBase.java
deleted file mode 100644
index 2e76f9a9f..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/upload/DelayedMultiUploaderBase.java
+++ /dev/null
@@ -1,44 +0,0 @@
-package com.teamscale.jacoco.agent.upload;
-
-import java.util.Collection;
-import java.util.stream.Collectors;
-
-import org.slf4j.Logger;
-
-import com.teamscale.jacoco.agent.logging.LoggingUtils;
-import com.teamscale.report.jacoco.CoverageFile;
-
-/**
- * Base class for wrapper uploaders that allow uploading the same coverage to
- * multiple locations.
- */
-public abstract class DelayedMultiUploaderBase implements IUploader {
-
- /** Logger. */
- protected final Logger logger = LoggingUtils.getLogger(this);
-
- @Override
- public synchronized void upload(CoverageFile file) {
- Collection wrappedUploaders = getWrappedUploaders();
- wrappedUploaders.forEach(uploader -> file.acquireReference());
- if (wrappedUploaders.isEmpty()) {
- logger.warn("No commits have been found yet to which coverage should be uploaded. Discarding coverage");
- } else {
- for (IUploader wrappedUploader : wrappedUploaders) {
- wrappedUploader.upload(file);
- }
- }
- }
-
- @Override
- public String describe() {
- Collection wrappedUploaders = getWrappedUploaders();
- if (!wrappedUploaders.isEmpty()) {
- return wrappedUploaders.stream().map(IUploader::describe).collect(Collectors.joining(", "));
- }
- return "Temporary stand-in until commit is resolved";
- }
-
- /** Returns the actual uploaders that this multiuploader wraps. */
- protected abstract Collection getWrappedUploaders();
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/upload/HttpZipUploaderBase.java b/agent/src/main/java/com/teamscale/jacoco/agent/upload/HttpZipUploaderBase.java
deleted file mode 100644
index 3d36723cd..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/upload/HttpZipUploaderBase.java
+++ /dev/null
@@ -1,153 +0,0 @@
-package com.teamscale.jacoco.agent.upload;
-
-import com.teamscale.client.FileSystemUtils;
-import com.teamscale.client.HttpUtils;
-import com.teamscale.jacoco.agent.logging.LoggingUtils;
-import com.teamscale.jacoco.agent.util.Benchmark;
-import com.teamscale.report.jacoco.CoverageFile;
-import okhttp3.HttpUrl;
-import okhttp3.OkHttpClient;
-import okhttp3.ResponseBody;
-import org.slf4j.Logger;
-import retrofit2.Response;
-import retrofit2.Retrofit;
-
-import java.io.File;
-import java.io.FileOutputStream;
-import java.io.IOException;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.util.List;
-import java.util.zip.ZipEntry;
-import java.util.zip.ZipOutputStream;
-
-/** Base class for uploading the coverage zip to a provided url */
-public abstract class HttpZipUploaderBase implements IUploader {
-
- /** The logger. */
- protected final Logger logger = LoggingUtils.getLogger(this);
-
- /** The URL to upload to. */
- protected HttpUrl uploadUrl;
-
- /** Additional files to include in the uploaded zip. */
- protected final List additionalMetaDataFiles;
-
- /** The API class. */
- private final Class apiClass;
-
- /** The API which performs the upload */
- private T api;
-
- /** Constructor. */
- public HttpZipUploaderBase(HttpUrl uploadUrl, List additionalMetaDataFiles, Class apiClass) {
- this.uploadUrl = uploadUrl;
- this.additionalMetaDataFiles = additionalMetaDataFiles;
- this.apiClass = apiClass;
- }
-
- /** Template method to configure the OkHttp Client. */
- protected void configureOkHttp(OkHttpClient.Builder builder) {
- }
-
- /** Returns the API for creating request to the http uploader */
- protected T getApi() {
- if (api == null) {
- Retrofit retrofit = HttpUtils.createRetrofit(retrofitBuilder -> retrofitBuilder.baseUrl(uploadUrl),
- this::configureOkHttp);
- api = retrofit.create(apiClass);
- }
-
- return api;
- }
-
- /** Uploads the coverage zip to the server */
- protected abstract Response uploadCoverageZip(File coverageFile)
- throws IOException, UploaderException;
-
- @Override
- public void upload(CoverageFile coverageFile) {
- try (Benchmark ignored = new Benchmark("Uploading report via HTTP")) {
- if (tryUpload(coverageFile)) {
- coverageFile.delete();
- } else {
- logger.warn("Failed to upload coverage to Teamscale. "
- + "Won't delete local file {} so that the upload can automatically be retried upon profiler restart. "
- + "Upload can also be retried manually.", coverageFile);
- if (this instanceof IUploadRetry) {
- ((IUploadRetry) this).markFileForUploadRetry(coverageFile);
- }
- }
- } catch (IOException e) {
- logger.warn("Could not delete file {} after upload", coverageFile);
- }
- }
-
- /** Performs the upload and returns true if successful. */
- protected boolean tryUpload(CoverageFile coverageFile) {
- logger.debug("Uploading coverage to {}", uploadUrl);
-
- File zipFile;
- try {
- zipFile = createZipFile(coverageFile);
- } catch (IOException e) {
- logger.error("Failed to compile coverage zip file for upload to {}", uploadUrl, e);
- return false;
- }
-
- try {
- Response response = uploadCoverageZip(zipFile);
- if (response.isSuccessful()) {
- return true;
- }
-
- String errorBody = "";
- if (response.errorBody() != null) {
- errorBody = response.errorBody().string();
- }
-
- logger.error("Failed to upload coverage to {}. Request failed with error code {}. Error:\n{}", uploadUrl,
- response.code(), errorBody);
- return false;
- } catch (IOException e) {
- logger.error("Failed to upload coverage to {}. Probably a network problem", uploadUrl, e);
- return false;
- } catch (UploaderException e) {
- logger.error("Failed to upload coverage to {}. The configuration is probably incorrect", uploadUrl, e);
- return false;
- } finally {
- zipFile.delete();
- }
- }
-
- /**
- * Creates the zip file in the system temp directory to upload which includes the given coverage XML and all
- * {@link #additionalMetaDataFiles}. The file is marked to be deleted on exit.
- */
- private File createZipFile(CoverageFile coverageFile) throws IOException {
- File zipFile = Files.createTempFile(coverageFile.getNameWithoutExtension(), ".zip").toFile();
- zipFile.deleteOnExit();
- try (FileOutputStream fileOutputStream = new FileOutputStream(zipFile);
- ZipOutputStream zipOutputStream = new ZipOutputStream(fileOutputStream)) {
- fillZipFile(zipOutputStream, coverageFile);
- return zipFile;
- }
- }
-
- /**
- * Fills the upload zip file with the given coverage XML and all {@link #additionalMetaDataFiles}.
- */
- private void fillZipFile(ZipOutputStream zipOutputStream, CoverageFile coverageFile) throws IOException {
- zipOutputStream.putNextEntry(new ZipEntry(getZipEntryCoverageFileName(coverageFile)));
- coverageFile.copyStream(zipOutputStream);
-
- for (Path additionalFile : additionalMetaDataFiles) {
- zipOutputStream.putNextEntry(new ZipEntry(additionalFile.getFileName().toString()));
- zipOutputStream.write(FileSystemUtils.readFileBinary(additionalFile.toFile()));
- }
- }
-
- protected String getZipEntryCoverageFileName(CoverageFile coverageFile) {
- return "coverage.xml";
- }
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/upload/IUploader.java b/agent/src/main/java/com/teamscale/jacoco/agent/upload/IUploader.java
deleted file mode 100644
index 0cc1fdcb1..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/upload/IUploader.java
+++ /dev/null
@@ -1,18 +0,0 @@
-package com.teamscale.jacoco.agent.upload;
-
-import com.teamscale.report.jacoco.CoverageFile;
-
-/** Uploads coverage reports. */
-public interface IUploader {
-
- /**
- * Uploads the given coverage file. If the upload was successful, the coverage
- * file on disk will be deleted. Otherwise the file is left on disk and a
- * warning is logged.
- */
- void upload(CoverageFile coverageFile);
-
- /** Human-readable description of the uploader. */
- String describe();
-
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/upload/LocalDiskUploader.java b/agent/src/main/java/com/teamscale/jacoco/agent/upload/LocalDiskUploader.java
deleted file mode 100644
index 67c01f3ae..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/upload/LocalDiskUploader.java
+++ /dev/null
@@ -1,21 +0,0 @@
-package com.teamscale.jacoco.agent.upload;
-
-import com.teamscale.report.jacoco.CoverageFile;
-
-/**
- * Dummy uploader which keeps the coverage file written by the agent on disk,
- * but does not actually perform uploads.
- */
-public class LocalDiskUploader implements IUploader {
- @Override
- public void upload(CoverageFile coverageFile) {
- // Don't delete the file here. We want to store the file permanently on disk in
- // case no uploader is configured.
- }
-
- @Override
- public String describe() {
- return "configured output directory on the local disk";
- }
-
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/upload/UploaderException.java b/agent/src/main/java/com/teamscale/jacoco/agent/upload/UploaderException.java
deleted file mode 100644
index a022c25bd..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/upload/UploaderException.java
+++ /dev/null
@@ -1,36 +0,0 @@
-package com.teamscale.jacoco.agent.upload;
-
-import okhttp3.ResponseBody;
-import retrofit2.Response;
-
-import java.io.IOException;
-
-/**
- * Exception thrown from an uploader. Either during the upload or in the validation process.
- */
-public class UploaderException extends Exception {
-
- /** Constructor */
- public UploaderException(String message, Exception e) {
- super(message, e);
- }
-
- /** Constructor */
- public UploaderException(String message) {
- super(message);
- }
-
- /** Constructor */
- public UploaderException(String message, Response response) {
- super(createResponseMessage(message, response));
- }
-
- private static String createResponseMessage(String message, Response response) {
- try {
- String errorBodyMessage = response.errorBody().string();
- return String.format("%s (%s): \n%s", message, response.code(), errorBodyMessage);
- } catch (IOException | NullPointerException e) {
- return message;
- }
- }
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/upload/artifactory/ArtifactoryConfig.java b/agent/src/main/java/com/teamscale/jacoco/agent/upload/artifactory/ArtifactoryConfig.java
deleted file mode 100644
index fceead42b..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/upload/artifactory/ArtifactoryConfig.java
+++ /dev/null
@@ -1,177 +0,0 @@
-package com.teamscale.jacoco.agent.upload.artifactory;
-
-import com.teamscale.client.StringUtils;
-import com.teamscale.jacoco.agent.commit_resolution.git_properties.CommitInfo;
-import com.teamscale.jacoco.agent.commit_resolution.git_properties.GitPropertiesLocatorUtils;
-import com.teamscale.jacoco.agent.commit_resolution.git_properties.InvalidGitPropertiesException;
-import com.teamscale.jacoco.agent.options.AgentOptionParseException;
-import com.teamscale.jacoco.agent.options.AgentOptionsParser;
-import com.teamscale.jacoco.agent.upload.UploaderException;
-import okhttp3.HttpUrl;
-import org.jetbrains.annotations.Nullable;
-
-import java.io.File;
-import java.io.IOException;
-import java.time.format.DateTimeFormatter;
-import java.util.List;
-
-/** Config necessary to upload files to an azure file storage. */
-public class ArtifactoryConfig {
- /**
- * Option to specify the artifactory URL. This shall be the entire path down to the directory to which the coverage
- * should be uploaded to, not only the base url of artifactory.
- */
- public static final String ARTIFACTORY_URL_OPTION = "artifactory-url";
-
- /**
- * Username that shall be used for basic auth. Alternative to basic auth is to use an API key with the
- * {@link ArtifactoryConfig#ARTIFACTORY_API_KEY_OPTION}
- */
- public static final String ARTIFACTORY_USER_OPTION = "artifactory-user";
-
- /**
- * Password that shall be used for basic auth. Alternative to basic auth is to use an API key with the
- * {@link ArtifactoryConfig#ARTIFACTORY_API_KEY_OPTION}
- */
- public static final String ARTIFACTORY_PASSWORD_OPTION = "artifactory-password";
-
- /**
- * API key that shall be used to authenticate requests to artifactory with the
- * {@link com.teamscale.jacoco.agent.upload.artifactory.ArtifactoryUploader#ARTIFACTORY_API_HEADER}. Alternatively
- * basic auth with username ({@link ArtifactoryConfig#ARTIFACTORY_USER_OPTION}) and password
- * ({@link ArtifactoryConfig#ARTIFACTORY_PASSWORD_OPTION}) can be used.
- */
- public static final String ARTIFACTORY_API_KEY_OPTION = "artifactory-api-key";
-
- /**
- * Option that specifies if the legacy path for uploading files to artifactory should be used instead of the new
- * standard path.
- */
- public static final String ARTIFACTORY_LEGACY_PATH_OPTION = "artifactory-legacy-path";
-
- /**
- * Option that specifies under which path the coverage file shall lie within the zip file that is created for the
- * upload.
- */
- public static final String ARTIFACTORY_ZIP_PATH_OPTION = "artifactory-zip-path";
-
- /**
- * Option that specifies intermediate directories which should be appended.
- */
- public static final String ARTIFACTORY_PATH_SUFFIX = "artifactory-path-suffix";
-
- /**
- * Specifies the location of the JAR file which includes the git.properties file.
- */
- public static final String ARTIFACTORY_GIT_PROPERTIES_JAR_OPTION = "artifactory-git-properties-jar";
-
- /**
- * Specifies the date format in which the commit timestamp in the git.properties file is formatted.
- */
- public static final String ARTIFACTORY_GIT_PROPERTIES_COMMIT_DATE_FORMAT_OPTION = "artifactory-git-properties-commit-date-format";
-
- /**
- * Specifies the partition for which the upload is.
- */
- public static final String ARTIFACTORY_PARTITION = "artifactory-partition";
-
- /** Related to {@link ArtifactoryConfig#ARTIFACTORY_USER_OPTION} */
- public HttpUrl url;
-
- /** Related to {@link ArtifactoryConfig#ARTIFACTORY_USER_OPTION} */
- public String user;
-
- /** Related to {@link ArtifactoryConfig#ARTIFACTORY_PASSWORD_OPTION} */
- public String password;
-
- /** Related to {@link ArtifactoryConfig#ARTIFACTORY_LEGACY_PATH_OPTION} */
- public boolean legacyPath = false;
-
- /** Related to {@link ArtifactoryConfig#ARTIFACTORY_ZIP_PATH_OPTION} */
- public String zipPath;
-
- /** Related to {@link ArtifactoryConfig#ARTIFACTORY_PATH_SUFFIX} */
- public String pathSuffix;
-
- /** The information regarding a commit. */
- public CommitInfo commitInfo;
-
- /** Related to {@link ArtifactoryConfig#ARTIFACTORY_API_KEY_OPTION} */
- public String apiKey;
-
- /** Related to {@link ArtifactoryConfig#ARTIFACTORY_PARTITION} */
- public String partition;
-
- /**
- * Handles all command-line options prefixed with 'artifactory-'
- *
- * @return true if it has successfully processed the given option.
- */
- public static boolean handleArtifactoryOptions(ArtifactoryConfig options, String key, String value) throws AgentOptionParseException {
- switch (key) {
- case ARTIFACTORY_URL_OPTION:
- options.url = AgentOptionsParser.parseUrl(key, value);
- return true;
- case ARTIFACTORY_USER_OPTION:
- options.user = value;
- return true;
- case ARTIFACTORY_PASSWORD_OPTION:
- options.password = value;
- return true;
- case ARTIFACTORY_LEGACY_PATH_OPTION:
- options.legacyPath = Boolean.parseBoolean(value);
- return true;
- case ARTIFACTORY_ZIP_PATH_OPTION:
- options.zipPath = StringUtils.stripSuffix(value, "/");
- return true;
- case ARTIFACTORY_PATH_SUFFIX:
- options.pathSuffix = StringUtils.stripSuffix(value, "/");
- return true;
- case ARTIFACTORY_API_KEY_OPTION:
- options.apiKey = value;
- return true;
- case ARTIFACTORY_PARTITION:
- options.partition = value;
- return true;
- default:
- return false;
- }
- }
-
- /** Checks if all required options are set to upload to artifactory. */
- public boolean hasAllRequiredFieldsSet() {
- boolean requiredAuthOptionsSet = (user != null && password != null) || apiKey != null;
- boolean partitionSet = partition != null || legacyPath;
- return url != null && partitionSet && requiredAuthOptionsSet;
- }
-
- /** Checks if all required fields are null. */
- public boolean hasAllRequiredFieldsNull() {
- return url == null && user == null && password == null && apiKey == null && partition == null;
- }
-
- /** Checks whether commit and revision are set. */
- public boolean hasCommitInfo() {
- return commitInfo != null;
- }
-
- /** Parses the commit information form a git.properties file. */
- public static CommitInfo parseGitProperties(
- File jarFile, boolean searchRecursively, @Nullable DateTimeFormatter gitPropertiesCommitTimeFormat)
- throws UploaderException {
- try {
- List commitInfo = GitPropertiesLocatorUtils.getCommitInfoFromGitProperties(jarFile, true, searchRecursively, gitPropertiesCommitTimeFormat);
- if (commitInfo.isEmpty()) {
- throw new UploaderException("Found no git.properties files in " + jarFile);
- }
- if (commitInfo.size() > 1) {
- throw new UploaderException("Found multiple git.properties files in " + jarFile
- + ". Uploading to multiple projects is currently not possible with Artifactory. "
- + "Please contact CQSE if you need this feature.");
- }
- return commitInfo.get(0);
- } catch (IOException | InvalidGitPropertiesException e) {
- throw new UploaderException("Could not locate a valid git.properties file in " + jarFile, e);
- }
- }
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/upload/artifactory/ArtifactoryUploader.java b/agent/src/main/java/com/teamscale/jacoco/agent/upload/artifactory/ArtifactoryUploader.java
deleted file mode 100644
index 248fdc700..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/upload/artifactory/ArtifactoryUploader.java
+++ /dev/null
@@ -1,157 +0,0 @@
-package com.teamscale.jacoco.agent.upload.artifactory;
-
-import com.teamscale.client.CommitDescriptor;
-import com.teamscale.client.EReportFormat;
-import com.teamscale.client.FileSystemUtils;
-import com.teamscale.client.HttpUtils;
-import com.teamscale.client.StringUtils;
-import com.teamscale.jacoco.agent.commit_resolution.git_properties.CommitInfo;
-import com.teamscale.jacoco.agent.upload.HttpZipUploaderBase;
-import com.teamscale.jacoco.agent.upload.IUploadRetry;
-import com.teamscale.report.jacoco.CoverageFile;
-import okhttp3.Interceptor;
-import okhttp3.OkHttpClient;
-import okhttp3.Request;
-import okhttp3.ResponseBody;
-import retrofit2.Response;
-
-import java.io.File;
-import java.io.FileWriter;
-import java.io.IOException;
-import java.nio.file.Path;
-import java.util.List;
-import java.util.Properties;
-
-import static com.teamscale.jacoco.agent.upload.teamscale.ETeamscaleServerProperties.COMMIT;
-import static com.teamscale.jacoco.agent.upload.teamscale.ETeamscaleServerProperties.PARTITION;
-import static com.teamscale.jacoco.agent.upload.teamscale.ETeamscaleServerProperties.REVISION;
-import static com.teamscale.jacoco.agent.upload.teamscale.TeamscaleUploader.RETRY_UPLOAD_FILE_SUFFIX;
-
-/**
- * Uploads XMLs to Artifactory.
- */
-public class ArtifactoryUploader extends HttpZipUploaderBase implements IUploadRetry {
-
- /**
- * Header that can be used as alternative to basic authentication to authenticate requests against artifactory. For
- * details check https://www.jfrog.com/confluence/display/JFROG/Artifactory+REST+API
- */
- public static final String ARTIFACTORY_API_HEADER = "X-JFrog-Art-Api";
- private final ArtifactoryConfig artifactoryConfig;
- private final String coverageFormat;
- private String uploadPath;
-
- /** Constructor. */
- public ArtifactoryUploader(ArtifactoryConfig config, List additionalMetaDataFiles,
- EReportFormat reportFormat) {
- super(config.url, additionalMetaDataFiles, IArtifactoryUploadApi.class);
- this.artifactoryConfig = config;
- this.coverageFormat = reportFormat.name().toLowerCase();
- }
-
- @Override
- public void markFileForUploadRetry(CoverageFile coverageFile) {
- File uploadMetadataFile = new File(FileSystemUtils.replaceFilePathFilenameWith(
- FileSystemUtils.normalizeSeparators(coverageFile.toString()),
- coverageFile.getName() + RETRY_UPLOAD_FILE_SUFFIX));
- Properties properties = createArtifactoryProperties();
- try (FileWriter writer = new FileWriter(uploadMetadataFile)) {
- properties.store(writer, null);
- } catch (IOException e) {
- logger.warn(
- "Failed to create metadata file for automatic upload retry of {}. Please manually retry the coverage upload to Azure.",
- coverageFile);
- uploadMetadataFile.delete();
- }
- }
-
- @Override
- public void reupload(CoverageFile coverageFile, Properties reuploadProperties) {
- ArtifactoryConfig config = new ArtifactoryConfig();
- config.url = artifactoryConfig.url;
- config.user = artifactoryConfig.user;
- config.password = artifactoryConfig.password;
- config.legacyPath = artifactoryConfig.legacyPath;
- config.zipPath = artifactoryConfig.zipPath;
- config.pathSuffix = artifactoryConfig.pathSuffix;
- String revision = reuploadProperties.getProperty(REVISION.name());
- String commitString = reuploadProperties.getProperty(COMMIT.name());
- config.commitInfo = new CommitInfo(revision, CommitDescriptor.parse(commitString));
- config.apiKey = artifactoryConfig.apiKey;
- config.partition = StringUtils.emptyToNull(reuploadProperties.getProperty(PARTITION.name()));
- setUploadPath(coverageFile, config);
- super.upload(coverageFile);
- }
-
- /** Creates properties from the artifactory configs. */
- private Properties createArtifactoryProperties() {
- Properties properties = new Properties();
- properties.setProperty(REVISION.name(), artifactoryConfig.commitInfo.revision);
- properties.setProperty(COMMIT.name(), artifactoryConfig.commitInfo.commit.toString());
- properties.setProperty(PARTITION.name(), StringUtils.nullToEmpty(artifactoryConfig.partition));
- return properties;
- }
-
- @Override
- protected void configureOkHttp(OkHttpClient.Builder builder) {
- super.configureOkHttp(builder);
- if (artifactoryConfig.apiKey != null) {
- builder.addInterceptor(getArtifactoryApiHeaderInterceptor());
- } else {
- builder.addInterceptor(
- HttpUtils.getBasicAuthInterceptor(artifactoryConfig.user, artifactoryConfig.password));
- }
- }
-
- private void setUploadPath(CoverageFile coverageFile, ArtifactoryConfig artifactoryConfig) {
- if (artifactoryConfig.legacyPath) {
- this.uploadPath = String.join("/", artifactoryConfig.commitInfo.commit.branchName,
- artifactoryConfig.commitInfo.commit.timestamp + "-" + artifactoryConfig.commitInfo.revision,
- coverageFile.getNameWithoutExtension() + ".zip");
- } else if (artifactoryConfig.pathSuffix == null) {
- this.uploadPath = String.join("/", "uploads", artifactoryConfig.commitInfo.commit.branchName,
- artifactoryConfig.commitInfo.commit.timestamp + "-" + artifactoryConfig.commitInfo.revision,
- artifactoryConfig.partition, coverageFormat, coverageFile.getNameWithoutExtension() + ".zip");
- } else {
- this.uploadPath = String.join("/", "uploads", artifactoryConfig.commitInfo.commit.branchName,
- artifactoryConfig.commitInfo.commit.timestamp + "-" + artifactoryConfig.commitInfo.revision,
- artifactoryConfig.partition, coverageFormat, artifactoryConfig.pathSuffix,
- coverageFile.getNameWithoutExtension() + ".zip");
- }
- }
-
- @Override
- public void upload(CoverageFile coverageFile) {
- setUploadPath(coverageFile, this.artifactoryConfig);
- super.upload(coverageFile);
- }
-
- @Override
- protected Response uploadCoverageZip(File zipFile) throws IOException {
- return getApi().uploadCoverageZip(uploadPath, zipFile);
- }
-
- @Override
- protected String getZipEntryCoverageFileName(CoverageFile coverageFile) {
- String path = coverageFile.getName();
- if (!StringUtils.isEmpty(artifactoryConfig.zipPath)) {
- path = artifactoryConfig.zipPath + "/" + path;
- }
-
- return path;
- }
-
- /** {@inheritDoc} */
- @Override
- public String describe() {
- return "Uploading to " + uploadUrl;
- }
-
- private Interceptor getArtifactoryApiHeaderInterceptor() {
- return chain -> {
- Request newRequest = chain.request().newBuilder().header(ARTIFACTORY_API_HEADER, artifactoryConfig.apiKey)
- .build();
- return chain.proceed(newRequest);
- };
- }
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/upload/artifactory/IArtifactoryUploadApi.java b/agent/src/main/java/com/teamscale/jacoco/agent/upload/artifactory/IArtifactoryUploadApi.java
deleted file mode 100644
index 316e48a14..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/upload/artifactory/IArtifactoryUploadApi.java
+++ /dev/null
@@ -1,36 +0,0 @@
-/*-------------------------------------------------------------------------+
-| |
-| Copyright (c) 2009-2018 CQSE GmbH |
-| |
-+-------------------------------------------------------------------------*/
-package com.teamscale.jacoco.agent.upload.artifactory;
-
-import okhttp3.MediaType;
-import okhttp3.RequestBody;
-import okhttp3.ResponseBody;
-import retrofit2.Call;
-import retrofit2.Response;
-import retrofit2.Retrofit;
-import retrofit2.http.Body;
-import retrofit2.http.PUT;
-import retrofit2.http.Path;
-
-import java.io.File;
-import java.io.IOException;
-
-/** {@link Retrofit} API specification for the {@link ArtifactoryUploader}. */
-public interface IArtifactoryUploadApi {
-
- /** The upload API call. */
- @PUT("{path}")
- Call upload(@Path("path") String path, @Body RequestBody uploadedFile);
-
- /**
- * Convenience method to perform an upload for a coverage zip.
- */
- default Response uploadCoverageZip(String path, File data) throws IOException {
- RequestBody body = RequestBody.create(MediaType.parse("application/zip"), data);
- return upload(path, body).execute();
- }
-
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/upload/azure/AzureFileStorageConfig.java b/agent/src/main/java/com/teamscale/jacoco/agent/upload/azure/AzureFileStorageConfig.java
deleted file mode 100644
index 5ae61e326..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/upload/azure/AzureFileStorageConfig.java
+++ /dev/null
@@ -1,44 +0,0 @@
-package com.teamscale.jacoco.agent.upload.azure;
-
-import com.teamscale.jacoco.agent.options.AgentOptionParseException;
-import com.teamscale.jacoco.agent.options.AgentOptionsParser;
-import okhttp3.HttpUrl;
-
-/** Config necessary to upload files to an azure file storage. */
-public class AzureFileStorageConfig {
- /** The URL to the azure file storage */
- public HttpUrl url;
-
- /** The access key of the azure file storage */
- public String accessKey;
-
- /** Checks if none of the required fields is null. */
- public boolean hasAllRequiredFieldsSet() {
- return url != null && accessKey != null;
- }
-
- /** Checks if all required fields are null. */
- public boolean hasAllRequiredFieldsNull() {
- return url == null && accessKey == null;
- }
-
- /**
- * Handles all command-line options prefixed with 'azure-'
- *
- * @return true if it has successfully processed the given option.
- */
- public static boolean handleAzureFileStorageOptions(AzureFileStorageConfig azureFileStorageConfig, String key,
- String value)
- throws AgentOptionParseException {
- switch (key) {
- case "azure-url":
- azureFileStorageConfig.url = AgentOptionsParser.parseUrl(key, value);
- return true;
- case "azure-key":
- azureFileStorageConfig.accessKey = value;
- return true;
- default:
- return false;
- }
- }
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/upload/azure/AzureFileStorageHttpUtils.java b/agent/src/main/java/com/teamscale/jacoco/agent/upload/azure/AzureFileStorageHttpUtils.java
deleted file mode 100644
index ac0e1792e..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/upload/azure/AzureFileStorageHttpUtils.java
+++ /dev/null
@@ -1,133 +0,0 @@
-package com.teamscale.jacoco.agent.upload.azure;
-
-import com.teamscale.jacoco.agent.upload.UploaderException;
-import com.teamscale.jacoco.agent.util.Assertions;
-
-import javax.crypto.Mac;
-import javax.crypto.spec.SecretKeySpec;
-import java.io.UnsupportedEncodingException;
-import java.security.InvalidKeyException;
-import java.security.NoSuchAlgorithmException;
-import java.time.LocalDateTime;
-import java.time.ZoneId;
-import java.time.format.DateTimeFormatter;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Base64;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Objects;
-import java.util.stream.Collectors;
-
-import static com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.CONTENT_ENCODING;
-import static com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.CONTENT_LANGUAGE;
-import static com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.CONTENT_LENGTH;
-import static com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.CONTENT_MD_5;
-import static com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.CONTENT_TYPE;
-import static com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.DATE;
-import static com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.IF_MATCH;
-import static com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.IF_MODIFIED_SINCE;
-import static com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.IF_NONE_MATCH;
-import static com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.IF_UNMODIFIED_SINCE;
-import static com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.RANGE;
-import static com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.X_MS_DATE;
-import static com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.X_MS_VERSION;
-
-/** Utils class for communicating with an azure file storage. */
-/* package */ class AzureFileStorageHttpUtils {
-
- /** Version of the azure file storage. Must be in every request */
- private static final String VERSION = "2018-03-28";
-
- /** Formatting pattern for every date in a request */
- private static final DateTimeFormatter FORMAT = DateTimeFormatter.ofPattern("E, dd MMM y HH:mm:ss z").withZone(
- ZoneId.of("GMT"));
-
-
- /** Creates the string that must be signed as the authorization for the request. */
- private static String createSignString(EHttpMethod httpMethod, Map headers, String account,
- String path, Map queryParameters) {
- Assertions.isTrue(headers.keySet().containsAll(Arrays.asList(X_MS_DATE, X_MS_VERSION)),
- "Headers for the azure request cannot be empty! At least 'x-ms-version' and 'x-ms-date' must be set");
-
- Map xmsHeader = headers.entrySet().stream().filter(x -> x.getKey().startsWith("x-ms"))
- .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
-
- return String.join("\n", httpMethod.toString(),
- getStringOrEmpty(headers, CONTENT_ENCODING),
- getStringOrEmpty(headers, CONTENT_LANGUAGE),
- getStringOrEmpty(headers, CONTENT_LENGTH),
- getStringOrEmpty(headers, CONTENT_MD_5),
- getStringOrEmpty(headers, CONTENT_TYPE),
- getStringOrEmpty(headers, DATE),
- getStringOrEmpty(headers, IF_MODIFIED_SINCE),
- getStringOrEmpty(headers, IF_MATCH),
- getStringOrEmpty(headers, IF_NONE_MATCH),
- getStringOrEmpty(headers, IF_UNMODIFIED_SINCE),
- getStringOrEmpty(headers, RANGE),
- createCanonicalizedString(xmsHeader),
- createCanonicalizedResources(account, path, queryParameters));
- }
-
- /** Returns the value from the map for the given key or an empty string if the key does not exist. */
- private static String getStringOrEmpty(Map map, String key) {
- return Objects.toString(map.get(key), "");
- }
-
- /** Creates the string for the canonicalized resources. */
- private static String createCanonicalizedResources(String account, String path, Map options) {
- String canonicalizedResources = String.format("/%s%s", account, path);
-
- if (options.size() > 0) {
- canonicalizedResources += "\n" + createCanonicalizedString(options);
- }
-
- return canonicalizedResources;
- }
-
- /** Creates a string with a map where each key-value pair is in a newline separated by a colon. */
- private static String createCanonicalizedString(Map options) {
- List sortedKeys = new ArrayList<>(options.keySet());
- sortedKeys.sort(String::compareTo);
-
- List values = sortedKeys.stream()
- .map(key -> String.format("%s:%s", key, options.get(key))).collect(Collectors.toList());
- return String.join("\n", values);
- }
-
- /** Creates the string which is needed for the authorization of an azure file storage request. */
- /* package */
- static String getAuthorizationString(EHttpMethod method, String account, String key, String path,
- Map headers, Map queryParameters)
- throws UploaderException {
- String stringToSign = createSignString(method, headers, account, path, queryParameters);
-
- try {
- Mac mac = Mac.getInstance("HmacSHA256");
- mac.init(new SecretKeySpec(Base64.getDecoder().decode(key), "HmacSHA256"));
- String authKey = new String(Base64.getEncoder().encode(mac.doFinal(stringToSign.getBytes("UTF-8"))));
- return "SharedKey " + account + ":" + authKey;
- } catch (NoSuchAlgorithmException | UnsupportedEncodingException e) {
- throw new UploaderException("Something is really wrong...", e);
- } catch (InvalidKeyException | IllegalArgumentException e) {
- throw new UploaderException(String.format("The given access key is malformed: %s", key), e);
- }
- }
-
- /** Returns the list of headers which must be present at every request */
- /* package */
- static Map getBaseHeaders() {
- Map headers = new HashMap<>();
- headers.put(X_MS_VERSION, AzureFileStorageHttpUtils.VERSION);
- headers.put(X_MS_DATE, FORMAT.format(LocalDateTime.now()));
- return headers;
- }
-
- /** Simple enum for all available HTTP methods. */
- public enum EHttpMethod {
- PUT,
- HEAD
- }
-}
-
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/upload/azure/AzureFileStorageUploader.java b/agent/src/main/java/com/teamscale/jacoco/agent/upload/azure/AzureFileStorageUploader.java
deleted file mode 100644
index 3b7d1caed..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/upload/azure/AzureFileStorageUploader.java
+++ /dev/null
@@ -1,254 +0,0 @@
-package com.teamscale.jacoco.agent.upload.azure;
-
-import com.teamscale.client.EReportFormat;
-import com.teamscale.client.FileSystemUtils;
-import com.teamscale.jacoco.agent.upload.HttpZipUploaderBase;
-import com.teamscale.jacoco.agent.upload.IUploadRetry;
-import com.teamscale.jacoco.agent.upload.UploaderException;
-import com.teamscale.report.jacoco.CoverageFile;
-import okhttp3.MediaType;
-import okhttp3.RequestBody;
-import okhttp3.ResponseBody;
-import retrofit2.Response;
-
-import java.io.File;
-import java.io.IOException;
-import java.nio.file.Path;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Properties;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-
-import static com.teamscale.jacoco.agent.upload.azure.AzureFileStorageHttpUtils.EHttpMethod.HEAD;
-import static com.teamscale.jacoco.agent.upload.azure.AzureFileStorageHttpUtils.EHttpMethod.PUT;
-import static com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.AUTHORIZATION;
-import static com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.CONTENT_LENGTH;
-import static com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.CONTENT_TYPE;
-import static com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.X_MS_CONTENT_LENGTH;
-import static com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.X_MS_RANGE;
-import static com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.X_MS_TYPE;
-import static com.teamscale.jacoco.agent.upload.azure.AzureHttpHeader.X_MS_WRITE;
-import static com.teamscale.jacoco.agent.upload.teamscale.TeamscaleUploader.RETRY_UPLOAD_FILE_SUFFIX;
-
-/** Uploads the coverage archive to a provided azure file storage. */
-public class AzureFileStorageUploader extends HttpZipUploaderBase implements IUploadRetry {
-
- /** Pattern matches the host of a azure file storage */
- private static final Pattern AZURE_FILE_STORAGE_HOST_PATTERN = Pattern
- .compile("^(\\w*)\\.file\\.core\\.windows\\.net$");
-
- /** The access key for the azure file storage */
- private final String accessKey;
-
- /** The account for the azure file storage */
- private final String account;
-
- /** Constructor. */
- public AzureFileStorageUploader(AzureFileStorageConfig config, List additionalMetaDataFiles)
- throws UploaderException {
- super(config.url, additionalMetaDataFiles, IAzureUploadApi.class);
- this.accessKey = config.accessKey;
- this.account = getAccount();
-
- validateUploadUrl();
- }
-
- @Override
- public void markFileForUploadRetry(CoverageFile coverageFile) {
- File uploadMetadataFile = new File(FileSystemUtils.replaceFilePathFilenameWith(
- FileSystemUtils.normalizeSeparators(coverageFile.toString()),
- coverageFile.getName() + RETRY_UPLOAD_FILE_SUFFIX));
- try {
- uploadMetadataFile.createNewFile();
- } catch (IOException e) {
- logger.warn(
- "Failed to create metadata file for automatic upload retry of {}. Please manually retry the coverage upload to Azure.",
- coverageFile);
- uploadMetadataFile.delete();
- }
- }
-
- @Override
- public void reupload(CoverageFile coverageFile, Properties properties) {
- // The azure uploader does not have any special reupload properties, so it will
- // just use the normal upload instead.
- this.upload(coverageFile);
- }
-
- /**
- * Extracts and returns the account of the provided azure file storage from the URL.
- */
- private String getAccount() throws UploaderException {
- Matcher matcher = AZURE_FILE_STORAGE_HOST_PATTERN.matcher(this.uploadUrl.host());
- if (matcher.matches()) {
- return matcher.group(1);
- } else {
- throw new UploaderException(String.format("URL is malformed. Must be in the format "
- + "\"https://.file.core.windows.net//\", but was instead: %s", uploadUrl));
- }
- }
-
- @Override
- public String describe() {
- return String.format("Uploading coverage to the Azure File Storage at %s", this.uploadUrl);
- }
-
- @Override
- protected Response uploadCoverageZip(File zipFile) throws IOException, UploaderException {
- String fileName = createFileName();
- if (checkFile(fileName).isSuccessful()) {
- logger.warn(String.format("The file %s does already exists at %s", fileName, uploadUrl));
- }
-
- return createAndFillFile(zipFile, fileName);
- }
-
- /**
- * Makes sure that the upload url is valid and that it exists on the file storage. If some directories do not
- * exists, they will be created.
- */
- private void validateUploadUrl() throws UploaderException {
- List pathParts = this.uploadUrl.pathSegments();
-
- if (pathParts.size() < 2) {
- throw new UploaderException(String.format(
- "%s is too short for a file path on the storage. "
- + "At least the share must be provided: https://.file.core.windows.net//",
- uploadUrl.url().getPath()));
- }
-
- try {
- checkAndCreatePath(pathParts);
- } catch (IOException e) {
- throw new UploaderException(String.format(
- "Checking the validity of %s failed. "
- + "There is probably something wrong with the URL or a problem with the account/key: ",
- this.uploadUrl.url().getPath()), e);
- }
- }
-
- /**
- * Checks the directory path in the azure url. Creates any missing directories.
- */
- private void checkAndCreatePath(List pathParts) throws IOException, UploaderException {
- for (int i = 2; i <= pathParts.size() - 1; i++) {
- String directoryPath = String.format("/%s/", String.join("/", pathParts.subList(0, i)));
- if (!checkDirectory(directoryPath).isSuccessful()) {
- Response mkdirResponse = createDirectory(directoryPath);
- if (!mkdirResponse.isSuccessful()) {
- throw new UploaderException(String.format("Creation of path '/%s' was unsuccessful", directoryPath),
- mkdirResponse);
- }
- }
- }
- }
-
- /** Creates a file name for the zip-archive containing the coverage. */
- private String createFileName() {
- return String.format("%s-%s.zip", EReportFormat.JACOCO.name().toLowerCase(), System.currentTimeMillis());
- }
-
- /** Checks if the file with the given name exists */
- private Response checkFile(String fileName) throws IOException, UploaderException {
- String filePath = uploadUrl.url().getPath() + fileName;
-
- Map headers = AzureFileStorageHttpUtils.getBaseHeaders();
- Map queryParameters = new HashMap<>();
-
- String auth = AzureFileStorageHttpUtils.getAuthorizationString(HEAD, account, accessKey, filePath, headers,
- queryParameters);
-
- headers.put(AUTHORIZATION, auth);
- return getApi().head(filePath, headers, queryParameters).execute();
- }
-
- /** Checks if the directory given by the specified path does exist. */
- private Response checkDirectory(String directoryPath) throws IOException, UploaderException {
- Map headers = AzureFileStorageHttpUtils.getBaseHeaders();
-
- Map queryParameters = new HashMap<>();
- queryParameters.put("restype", "directory");
-
- String auth = AzureFileStorageHttpUtils.getAuthorizationString(HEAD, account, accessKey, directoryPath, headers,
- queryParameters);
-
- headers.put(AUTHORIZATION, auth);
- return getApi().head(directoryPath, headers, queryParameters).execute();
- }
-
- /**
- * Creates the directory specified by the given path. The path must contain the share where it should be created
- * on.
- */
- private Response createDirectory(String directoryPath) throws IOException, UploaderException {
- Map headers = AzureFileStorageHttpUtils.getBaseHeaders();
-
- Map queryParameters = new HashMap<>();
- queryParameters.put("restype", "directory");
-
- String auth = AzureFileStorageHttpUtils.getAuthorizationString(PUT, account, accessKey, directoryPath, headers,
- queryParameters);
-
- headers.put(AUTHORIZATION, auth);
- return getApi().put(directoryPath, headers, queryParameters).execute();
- }
-
- /** Creates and fills a file with the given data and name. */
- private Response createAndFillFile(File zipFile, String fileName)
- throws UploaderException, IOException {
- Response response = createFile(zipFile, fileName);
- if (response.isSuccessful()) {
- return fillFile(zipFile, fileName);
- }
- logger.error(String.format("Creation of file '%s' was unsuccessful.", fileName));
- return response;
- }
-
- /**
- * Creates an empty file with the given name. The size is defined by the length of the given byte array.
- */
- private Response createFile(File zipFile, String fileName) throws IOException, UploaderException {
- String filePath = uploadUrl.url().getPath() + fileName;
-
- Map headers = AzureFileStorageHttpUtils.getBaseHeaders();
- headers.put(X_MS_CONTENT_LENGTH, String.valueOf(zipFile.length()));
- headers.put(X_MS_TYPE, "file");
-
- Map queryParameters = new HashMap<>();
-
- String auth = AzureFileStorageHttpUtils.getAuthorizationString(PUT, account, accessKey, filePath, headers,
- queryParameters);
-
- headers.put(AUTHORIZATION, auth);
- return getApi().put(filePath, headers, queryParameters).execute();
- }
-
- /**
- * Fills the file defined by the name with the given data. Should be used with {@link #createFile(File, String)},
- * because the request only writes exactly the length of the given data, so the file should be exactly as big as the
- * data, otherwise it will be partially filled or is not big enough.
- */
- private Response fillFile(File zipFile, String fileName) throws IOException, UploaderException {
- String filePath = uploadUrl.url().getPath() + fileName;
-
- String range = "bytes=0-" + (zipFile.length() - 1);
- String contentType = "application/octet-stream";
-
- Map headers = AzureFileStorageHttpUtils.getBaseHeaders();
- headers.put(X_MS_WRITE, "update");
- headers.put(X_MS_RANGE, range);
- headers.put(CONTENT_LENGTH, String.valueOf(zipFile.length()));
- headers.put(CONTENT_TYPE, contentType);
-
- Map queryParameters = new HashMap<>();
- queryParameters.put("comp", "range");
-
- String auth = AzureFileStorageHttpUtils.getAuthorizationString(PUT, account, accessKey, filePath, headers,
- queryParameters);
- headers.put(AUTHORIZATION, auth);
- RequestBody content = RequestBody.create(MediaType.parse(contentType), zipFile);
- return getApi().putData(filePath, headers, queryParameters, content).execute();
- }
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/upload/azure/AzureHttpHeader.java b/agent/src/main/java/com/teamscale/jacoco/agent/upload/azure/AzureHttpHeader.java
deleted file mode 100644
index 87ae52dca..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/upload/azure/AzureHttpHeader.java
+++ /dev/null
@@ -1,70 +0,0 @@
-package com.teamscale.jacoco.agent.upload.azure;
-
-/** Constants for the names of HTTP header used in a request to an Azure file storage. */
-/* package */ class AzureHttpHeader {
- /** Same as {@link #CONTENT_LENGTH} */
- /* package */ static final String X_MS_CONTENT_LENGTH = "x-ms-content-length";
-
- /** Same as {@link #DATE} */
- /* package */ static final String X_MS_DATE = "x-ms-date";
-
- /** Same as {@link #RANGE} */
- /* package */ static final String X_MS_RANGE = "x-ms-range";
-
- /** Type of filesystem object which the request is referring to. Can be 'file' or 'directory'. */
- /* package */ static final String X_MS_TYPE = "x-ms-type";
-
- /** Version of the Azure file storage API */
- /* package */ static final String X_MS_VERSION = "x-ms-version";
-
- /**
- * Defines the type of write operation on a file. Can either be 'Update' or 'Clear'. For 'Update' the 'Range' and
- * 'Content-Length' headers must match, for 'Clear', 'Content-Length' must be set to 0.
- */
- /* package */ static final String X_MS_WRITE = "x-ms-write";
-
- /**
- * Defines the authorization and must contain the account name and signature. Must be given in the following format:
- * Authorization="[SharedKey|SharedKeyLite] :"
- */
- /* package */ static final String AUTHORIZATION = "Authorization";
-
- /** Content-Encoding */
- /* package */ static final String CONTENT_ENCODING = "Content-Encoding";
-
- /** Content-Language */
- /* package */ static final String CONTENT_LANGUAGE = "Content-Language";
-
- /** Content-Length */
- /* package */ static final String CONTENT_LENGTH = "Content-Length";
-
- /** The md5 hash of the sent content. */
- /* package */ static final String CONTENT_MD_5 = "Content-MD5";
-
- /** Content-Type */
- /* package */ static final String CONTENT_TYPE = "Content-Type";
-
- /** The date time of the request */
- /* package */ static final String DATE = "Date";
-
- /** Only send the response if the entity has not been modified since a specific time. */
- /* package */ static final String IF_UNMODIFIED_SINCE = "If-Unmodified-Since";
-
- /** Allows a 304 Not Modified to be returned if content is unchanged. */
- /* package */ static final String IF_MODIFIED_SINCE = "If-Modified-Since";
-
- /**
- * Only perform the action if the client supplied entity matches the same entity on the server. This is mainly for
- * methods like PUT to only update a resource if it has not been modified since the user last updated it.
- */
- /* package */ static final String IF_MATCH = "If-Match";
-
- /** Allows a 304 Not Modified to be returned if content is unchanged */
- /* package */ static final String IF_NONE_MATCH = "If-None-Match";
-
- /**
- * Specifies the range of bytes to be written. Both the start and end of the range must be specified. Must be given
- * in the following format: "bytes=startByte-endByte"
- */
- /* package */ static final String RANGE = "Range";
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/upload/azure/IAzureUploadApi.java b/agent/src/main/java/com/teamscale/jacoco/agent/upload/azure/IAzureUploadApi.java
deleted file mode 100644
index de56755cf..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/upload/azure/IAzureUploadApi.java
+++ /dev/null
@@ -1,43 +0,0 @@
-package com.teamscale.jacoco.agent.upload.azure;
-
-import okhttp3.RequestBody;
-import okhttp3.ResponseBody;
-import retrofit2.Call;
-import retrofit2.Retrofit;
-import retrofit2.http.Body;
-import retrofit2.http.HEAD;
-import retrofit2.http.HeaderMap;
-import retrofit2.http.PUT;
-import retrofit2.http.Path;
-import retrofit2.http.QueryMap;
-
-import java.util.Map;
-
-/** {@link Retrofit} API specification for the {@link AzureFileStorageUploader}. */
-public interface IAzureUploadApi {
-
- /** PUT call to the azure file storage without any data in the body */
- @PUT("{path}")
- public Call put(
- @Path(value = "path", encoded = true) String path,
- @HeaderMap Map headers,
- @QueryMap Map query
- );
-
- /** PUT call to the azure file storage with data in the body */
- @PUT("{path}")
- public Call putData(
- @Path(value = "path", encoded = true) String path,
- @HeaderMap Map headers,
- @QueryMap Map query,
- @Body RequestBody content
- );
-
- /** HEAD call to the azure file storage */
- @HEAD("{path}")
- public Call head(
- @Path(value = "path", encoded = true) String path,
- @HeaderMap Map headers,
- @QueryMap Map query
- );
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/upload/delay/DelayedUploader.java b/agent/src/main/java/com/teamscale/jacoco/agent/upload/delay/DelayedUploader.java
deleted file mode 100644
index 178aee399..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/upload/delay/DelayedUploader.java
+++ /dev/null
@@ -1,116 +0,0 @@
-package com.teamscale.jacoco.agent.upload.delay;
-
-import java.io.IOException;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.util.concurrent.Executor;
-import java.util.concurrent.Executors;
-import java.util.function.Function;
-import java.util.stream.Stream;
-
-import org.slf4j.Logger;
-
-import com.teamscale.jacoco.agent.upload.IUploader;
-import com.teamscale.jacoco.agent.util.DaemonThreadFactory;
-import com.teamscale.jacoco.agent.logging.LoggingUtils;
-import com.teamscale.report.jacoco.CoverageFile;
-
-/**
- * Wraps an {@link IUploader} and in order to delay upload until a all
- * information describing a commit is asynchronously made available.
- */
-public class DelayedUploader implements IUploader {
-
- private final Executor executor;
- private final Logger logger = LoggingUtils.getLogger(this);
- private final Function wrappedUploaderFactory;
- private IUploader wrappedUploader = null;
- private final Path cacheDir;
-
- public DelayedUploader(Function wrappedUploaderFactory, Path cacheDir) {
- this(wrappedUploaderFactory, cacheDir, Executors.newSingleThreadExecutor(
- new DaemonThreadFactory(DelayedUploader.class, "Delayed cache upload thread")));
- }
-
- /**
- * Visible for testing. Allows tests to control the {@link Executor} to test the
- * asynchronous functionality of this class.
- */
- /* package */ DelayedUploader(Function wrappedUploaderFactory, Path cacheDir, Executor executor) {
- this.wrappedUploaderFactory = wrappedUploaderFactory;
- this.cacheDir = cacheDir;
- this.executor = executor;
-
- registerShutdownHook();
- }
-
- private void registerShutdownHook() {
- Runtime.getRuntime().addShutdownHook(new Thread(() -> {
- if (wrappedUploader == null) {
- logger.error("The application was shut down before a commit could be found. The recorded coverage"
- + " is still cached in {} but will not be automatically processed. You configured the"
- + " agent to auto-detect the commit to which the recorded coverage should be uploaded to"
- + " Teamscale. In order to fix this problem, you need to provide a git.properties file"
- + " in all of the profiled Jar/War/Ear/... files. If you're using Gradle or"
- + " Maven, you can use a plugin to create a proper git.properties file for you, see"
- + " https://docs.spring.io/spring-boot/docs/current/reference/html/howto.html#howto-git-info"
- + "\nTo debug problems with git.properties, please enable debug logging for the agent via"
- + " the logging-config parameter.", cacheDir.toAbsolutePath());
- }
- }));
- }
-
- @Override
- public synchronized void upload(CoverageFile file) {
- if (wrappedUploader == null) {
- logger.info("The commit to upload to has not yet been found. Caching coverage XML in {}",
- cacheDir.toAbsolutePath());
- } else {
- wrappedUploader.upload(file);
- }
- }
-
- @Override
- public String describe() {
- if (wrappedUploader != null) {
- return wrappedUploader.describe();
- }
- return "Temporary cache until commit is resolved: " + cacheDir.toAbsolutePath();
- }
-
- /**
- * Sets the commit to upload the XMLs to and asynchronously triggers the upload
- * of all cached XMLs. This method should only be called once.
- */
- public synchronized void setCommitAndTriggerAsynchronousUpload(T information) {
- if (wrappedUploader == null) {
- wrappedUploader = wrappedUploaderFactory.apply(information);
- logger.info("Commit to upload to has been found: {}. Uploading any cached XMLs now to {}", information,
- wrappedUploader.describe());
- executor.execute(this::uploadCachedXmls);
- } else {
- logger.error(
- "Tried to set upload commit multiple times (old uploader: {}, new commit: {})."
- + " This is a programming error. Please report a bug.",
- wrappedUploader.describe(), information);
- }
- }
-
- private void uploadCachedXmls() {
- try {
- if (!Files.isDirectory(cacheDir)) {
- // Found data before XML was dumped
- return;
- }
- Stream xmlFilesStream = Files.list(cacheDir).filter(path -> {
- String fileName = path.getFileName().toString();
- return fileName.startsWith("jacoco-") && fileName.endsWith(".xml");
- });
- xmlFilesStream.forEach(path -> wrappedUploader.upload(new CoverageFile(path.toFile())));
- logger.debug("Finished upload of cached XMLs to {}", wrappedUploader.describe());
- } catch (IOException e) {
- logger.error("Failed to list cached coverage XML files in {}", cacheDir.toAbsolutePath(), e);
- }
-
- }
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/upload/teamscale/DelayedTeamscaleMultiProjectUploader.java b/agent/src/main/java/com/teamscale/jacoco/agent/upload/teamscale/DelayedTeamscaleMultiProjectUploader.java
deleted file mode 100644
index 5c325925e..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/upload/teamscale/DelayedTeamscaleMultiProjectUploader.java
+++ /dev/null
@@ -1,55 +0,0 @@
-package com.teamscale.jacoco.agent.upload.teamscale;
-
-import com.teamscale.client.TeamscaleServer;
-import com.teamscale.jacoco.agent.commit_resolution.git_properties.CommitInfo;
-import com.teamscale.jacoco.agent.options.ProjectAndCommit;
-import com.teamscale.jacoco.agent.upload.DelayedMultiUploaderBase;
-import com.teamscale.jacoco.agent.upload.IUploader;
-
-import java.io.File;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.List;
-import java.util.function.BiFunction;
-
-/** Wrapper for {@link TeamscaleUploader} that allows to upload the same coverage file to multiple Teamscale projects. */
-public class DelayedTeamscaleMultiProjectUploader extends DelayedMultiUploaderBase implements IUploader {
-
- private final BiFunction teamscaleServerFactory;
- private final List teamscaleUploaders = new ArrayList<>();
-
- public DelayedTeamscaleMultiProjectUploader(
- BiFunction teamscaleServerFactory) {
- this.teamscaleServerFactory = teamscaleServerFactory;
- }
-
- public List getTeamscaleUploaders() {
- return teamscaleUploaders;
- }
-
- /**
- * Adds a teamscale project and commit as a possible new target to upload coverage to. Checks if the project and
- * commit are already registered as an upload target and will prevent duplicate uploads.
- */
- public void addTeamscaleProjectAndCommit(File file, ProjectAndCommit projectAndCommit) {
-
- TeamscaleServer teamscaleServer = teamscaleServerFactory.apply(projectAndCommit.getProject(),
- projectAndCommit.getCommitInfo());
-
- if (this.teamscaleUploaders.stream().anyMatch(teamscaleUploader ->
- teamscaleUploader.getTeamscaleServer().hasSameProjectAndCommit(teamscaleServer)
- )) {
- logger.debug(
- "Project and commit in git.properties file {} are already registered as upload target. Coverage will not be uploaded multiple times to the same project {} and commit info {}.",
- file, projectAndCommit.getProject(), projectAndCommit.getCommitInfo());
- return;
- }
- TeamscaleUploader uploader = new TeamscaleUploader(teamscaleServer);
- teamscaleUploaders.add(uploader);
- }
-
- @Override
- protected Collection getWrappedUploaders() {
- return new ArrayList<>(teamscaleUploaders);
- }
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/upload/teamscale/ETeamscaleServerProperties.java b/agent/src/main/java/com/teamscale/jacoco/agent/upload/teamscale/ETeamscaleServerProperties.java
deleted file mode 100644
index e2bfb413f..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/upload/teamscale/ETeamscaleServerProperties.java
+++ /dev/null
@@ -1,26 +0,0 @@
-package com.teamscale.jacoco.agent.upload.teamscale;
-
-import com.teamscale.client.TeamscaleServer;
-
-/** Describes all the fields of the {@link TeamscaleServer}. */
-public enum ETeamscaleServerProperties {
-
- /** See {@link TeamscaleServer#url} */
- URL,
- /** See {@link TeamscaleServer#project} */
- PROJECT,
- /** See {@link TeamscaleServer#userName} */
- USER_NAME,
- /** See {@link TeamscaleServer#userAccessToken} */
- USER_ACCESS_TOKEN,
- /** See {@link TeamscaleServer#partition} */
- PARTITION,
- /** See {@link TeamscaleServer#commit} */
- COMMIT,
- /** See {@link TeamscaleServer#revision} */
- REVISION,
- /** See {@link TeamscaleServer#repository} */
- REPOSITORY,
- /** See {@link TeamscaleServer#getMessage()} */
- MESSAGE;
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/upload/teamscale/TeamscaleConfig.java b/agent/src/main/java/com/teamscale/jacoco/agent/upload/teamscale/TeamscaleConfig.java
deleted file mode 100644
index b3efb279c..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/upload/teamscale/TeamscaleConfig.java
+++ /dev/null
@@ -1,162 +0,0 @@
-package com.teamscale.jacoco.agent.upload.teamscale;
-
-import com.teamscale.client.CommitDescriptor;
-import com.teamscale.client.StringUtils;
-import com.teamscale.client.TeamscaleServer;
-import com.teamscale.jacoco.agent.options.AgentOptionParseException;
-import com.teamscale.jacoco.agent.options.AgentOptionsParser;
-import com.teamscale.jacoco.agent.options.FilePatternResolver;
-import com.teamscale.report.util.BashFileSkippingInputStream;
-import com.teamscale.report.util.ILogger;
-
-import java.io.File;
-import java.io.IOException;
-import java.nio.file.Files;
-import java.util.jar.JarInputStream;
-import java.util.jar.Manifest;
-
-import static com.teamscale.jacoco.agent.options.AgentOptionsParser.parsePath;
-
-/** Config necessary for direct Teamscale upload. */
-public class TeamscaleConfig {
-
- /** Option name that allows to specify to which branch coverage should be uploaded to (branch:timestamp). */
- public static final String TEAMSCALE_COMMIT_OPTION = "teamscale-commit";
-
- /** Option name that allows to specify a git commit hash to which coverage should be uploaded to. */
- public static final String TEAMSCALE_REVISION_OPTION = "teamscale-revision";
-
- /** Option name that allows to specify a jar file that contains the git commit hash in a MANIFEST.MF file. */
- public static final String TEAMSCALE_REVISION_MANIFEST_JAR_OPTION = "teamscale-revision-manifest-jar";
-
- /** Option name that allows to specify a jar file that contains the branch name and timestamp in a MANIFEST.MF file. */
- public static final String TEAMSCALE_COMMIT_MANIFEST_JAR_OPTION = "teamscale-commit-manifest-jar";
-
- /** Option name that allows to specify a partition to which coverage should be uploaded to. */
- public static final String TEAMSCALE_PARTITION_OPTION = "teamscale-partition";
-
- private final ILogger logger;
- private final FilePatternResolver filePatternResolver;
-
- public TeamscaleConfig(ILogger logger, FilePatternResolver filePatternResolver) {
- this.logger = logger;
- this.filePatternResolver = filePatternResolver;
- }
-
- /**
- * Handles all command line options prefixed with "teamscale-".
- *
- * @return true if it has successfully processed the given option.
- */
- public boolean handleTeamscaleOptions(TeamscaleServer teamscaleServer,
- String key, String value)
- throws AgentOptionParseException {
- switch (key) {
- case "teamscale-server-url":
- teamscaleServer.url = AgentOptionsParser.parseUrl(key, value);
- return true;
- case "teamscale-project":
- teamscaleServer.project = value;
- return true;
- case "teamscale-user":
- teamscaleServer.userName = value;
- return true;
- case "teamscale-access-token":
- teamscaleServer.userAccessToken = value;
- return true;
- case TEAMSCALE_PARTITION_OPTION:
- teamscaleServer.partition = value;
- return true;
- case TEAMSCALE_COMMIT_OPTION:
- teamscaleServer.commit = parseCommit(value);
- return true;
- case TEAMSCALE_COMMIT_MANIFEST_JAR_OPTION:
- teamscaleServer.commit = getCommitFromManifest(
- parsePath(filePatternResolver, key, value).toFile());
- return true;
- case "teamscale-message":
- teamscaleServer.setMessage(value);
- return true;
- case TEAMSCALE_REVISION_OPTION:
- teamscaleServer.revision = value;
- return true;
- case "teamscale-repository":
- teamscaleServer.repository = value;
- return true;
- case TEAMSCALE_REVISION_MANIFEST_JAR_OPTION:
- teamscaleServer.revision = getRevisionFromManifest(
- parsePath(filePatternResolver, key, value).toFile());
- return true;
- default:
- return false;
- }
- }
-
- /**
- * Parses the the string representation of a commit to a {@link CommitDescriptor} object.
- *
- * The expected format is "branch:timestamp".
- */
- private CommitDescriptor parseCommit(String commit) throws AgentOptionParseException {
- String[] split = commit.split(":");
- if (split.length != 2) {
- throw new AgentOptionParseException("Invalid commit given " + commit);
- }
- return new CommitDescriptor(split[0], split[1]);
- }
-
- /**
- * Reads `Branch` and `Timestamp` entries from the given jar/war file's manifest and builds a commit descriptor out
- * of it.
- */
- private CommitDescriptor getCommitFromManifest(File jarFile) throws AgentOptionParseException {
- Manifest manifest = getManifestFromJarFile(jarFile);
- String branch = manifest.getMainAttributes().getValue("Branch");
- String timestamp = manifest.getMainAttributes().getValue("Timestamp");
- if (StringUtils.isEmpty(branch)) {
- throw new AgentOptionParseException("No entry 'Branch' in MANIFEST");
- } else if (StringUtils.isEmpty(timestamp)) {
- throw new AgentOptionParseException("No entry 'Timestamp' in MANIFEST");
- }
- logger.debug("Found commit " + branch + ":" + timestamp + " in file " + jarFile);
- return new CommitDescriptor(branch, timestamp);
- }
-
- /**
- * Reads `Git_Commit` entry from the given jar/war file's manifest and sets it as revision.
- */
- private String getRevisionFromManifest(File jarFile) throws AgentOptionParseException {
- Manifest manifest = getManifestFromJarFile(jarFile);
- String revision = manifest.getMainAttributes().getValue("Revision");
- if (StringUtils.isEmpty(revision)) {
- // currently needed option for a customer
- if (manifest.getAttributes("Git") != null) {
- revision = manifest.getAttributes("Git").getValue("Git_Commit");
- }
-
- if (StringUtils.isEmpty(revision)) {
- throw new AgentOptionParseException("No entry 'Revision' in MANIFEST");
- }
- }
- logger.debug("Found revision " + revision + " in file " + jarFile);
- return revision;
- }
-
- /**
- * Reads the JarFile to extract the MANIFEST.MF.
- */
- private Manifest getManifestFromJarFile(File jarFile) throws AgentOptionParseException {
- try (JarInputStream jarStream = new JarInputStream(
- new BashFileSkippingInputStream(Files.newInputStream(jarFile.toPath())))) {
- Manifest manifest = jarStream.getManifest();
- if (manifest == null) {
- throw new AgentOptionParseException(
- "Unable to read manifest from " + jarFile + ". Maybe the manifest is corrupt?");
- }
- return manifest;
- } catch (IOException e) {
- throw new AgentOptionParseException("Reading jar " + jarFile.getAbsolutePath() + " for obtaining commit "
- + "descriptor from MANIFEST failed", e);
- }
- }
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/upload/teamscale/TeamscaleUploader.java b/agent/src/main/java/com/teamscale/jacoco/agent/upload/teamscale/TeamscaleUploader.java
deleted file mode 100644
index 39517e38e..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/upload/teamscale/TeamscaleUploader.java
+++ /dev/null
@@ -1,156 +0,0 @@
-package com.teamscale.jacoco.agent.upload.teamscale;
-
-import com.teamscale.client.CommitDescriptor;
-import com.teamscale.client.EReportFormat;
-import com.teamscale.client.FileSystemUtils;
-import com.teamscale.client.ITeamscaleService;
-import com.teamscale.client.ITeamscaleServiceKt;
-import com.teamscale.client.StringUtils;
-import com.teamscale.client.TeamscaleServer;
-import com.teamscale.client.TeamscaleServiceGenerator;
-import com.teamscale.jacoco.agent.logging.LoggingUtils;
-import com.teamscale.jacoco.agent.upload.IUploadRetry;
-import com.teamscale.jacoco.agent.upload.IUploader;
-import com.teamscale.jacoco.agent.util.AgentUtils;
-import com.teamscale.jacoco.agent.util.Benchmark;
-import com.teamscale.report.jacoco.CoverageFile;
-import org.slf4j.Logger;
-
-import java.io.File;
-import java.io.IOException;
-import java.io.OutputStreamWriter;
-import java.nio.charset.StandardCharsets;
-import java.nio.file.Files;
-import java.util.Properties;
-
-import static com.teamscale.jacoco.agent.upload.teamscale.ETeamscaleServerProperties.COMMIT;
-import static com.teamscale.jacoco.agent.upload.teamscale.ETeamscaleServerProperties.MESSAGE;
-import static com.teamscale.jacoco.agent.upload.teamscale.ETeamscaleServerProperties.PARTITION;
-import static com.teamscale.jacoco.agent.upload.teamscale.ETeamscaleServerProperties.PROJECT;
-import static com.teamscale.jacoco.agent.upload.teamscale.ETeamscaleServerProperties.REPOSITORY;
-import static com.teamscale.jacoco.agent.upload.teamscale.ETeamscaleServerProperties.REVISION;
-
-/** Uploads XML Coverage to a Teamscale instance. */
-public class TeamscaleUploader implements IUploader, IUploadRetry {
-
- /**
- * The properties file suffix for unsuccessful coverage uploads.
- */
- public static final String RETRY_UPLOAD_FILE_SUFFIX = "_upload-retry.properties";
-
- /** The logger. */
- private final Logger logger = LoggingUtils.getLogger(this);
-
- public TeamscaleServer getTeamscaleServer() {
- return teamscaleServer;
- }
-
- /** Teamscale server details. */
- private final TeamscaleServer teamscaleServer;
-
- /** Constructor. */
- public TeamscaleUploader(TeamscaleServer teamscaleServer) {
- this.teamscaleServer = teamscaleServer;
- }
-
- @Override
- public void upload(CoverageFile coverageFile) {
- doUpload(coverageFile, this.teamscaleServer);
- }
-
- @Override
- public void reupload(CoverageFile coverageFile, Properties reuploadProperties) {
- TeamscaleServer server = new TeamscaleServer();
- server.project = reuploadProperties.getProperty(PROJECT.name());
- server.commit = CommitDescriptor.parse(reuploadProperties.getProperty(COMMIT.name()));
- server.partition = reuploadProperties.getProperty(PARTITION.name());
- server.revision = StringUtils.emptyToNull(reuploadProperties.getProperty(REVISION.name()));
- server.repository = StringUtils.emptyToNull(reuploadProperties.getProperty(REPOSITORY.name()));
- server.userAccessToken = teamscaleServer.userAccessToken;
- server.userName = teamscaleServer.userName;
- server.url = teamscaleServer.url;
- server.setMessage(reuploadProperties.getProperty(MESSAGE.name()));
- doUpload(coverageFile, server);
- }
-
- private void doUpload(CoverageFile coverageFile, TeamscaleServer teamscaleServer) {
- try (Benchmark benchmark = new Benchmark("Uploading report to Teamscale")) {
- if (tryUploading(coverageFile, teamscaleServer)) {
- deleteCoverageFile(coverageFile);
- } else {
- logger.warn("Failed to upload coverage to Teamscale. "
- + "Won't delete local file {} so that the upload can automatically be retried upon profiler restart. "
- + "Upload can also be retried manually.", coverageFile);
- markFileForUploadRetry(coverageFile);
- }
- }
- }
-
- @Override
- public void markFileForUploadRetry(CoverageFile coverageFile) {
- File uploadMetadataFile = new File(FileSystemUtils.replaceFilePathFilenameWith(
- FileSystemUtils.normalizeSeparators(coverageFile.toString()),
- coverageFile.getName() + RETRY_UPLOAD_FILE_SUFFIX));
- Properties serverProperties = this.createServerProperties();
- try (OutputStreamWriter writer = new OutputStreamWriter(Files.newOutputStream(uploadMetadataFile.toPath()),
- StandardCharsets.UTF_8)) {
- serverProperties.store(writer, null);
- } catch (IOException e) {
- logger.warn(
- "Failed to create metadata file for automatic upload retry of {}. Please manually retry the coverage upload to Teamscale.",
- coverageFile);
- uploadMetadataFile.delete();
- }
- }
-
- /**
- * Creates server properties to be written in a properties file.
- */
- private Properties createServerProperties() {
- Properties serverProperties = new Properties();
- serverProperties.setProperty(PROJECT.name(), teamscaleServer.project);
- serverProperties.setProperty(PARTITION.name(), teamscaleServer.partition);
- if (teamscaleServer.commit != null) {
- serverProperties.setProperty(COMMIT.name(), teamscaleServer.commit.toString());
- }
- serverProperties.setProperty(REVISION.name(), StringUtils.nullToEmpty(teamscaleServer.revision));
- serverProperties.setProperty(REPOSITORY.name(), StringUtils.nullToEmpty(teamscaleServer.repository));
- serverProperties.setProperty(MESSAGE.name(), teamscaleServer.getMessage());
- return serverProperties;
- }
-
- private void deleteCoverageFile(CoverageFile coverageFile) {
- try {
- coverageFile.delete();
- } catch (IOException e) {
- logger.warn("The upload to Teamscale was successful, but the deletion of the coverage file {} failed. "
- + "You can delete it yourself anytime - it is no longer needed.", coverageFile, e);
- }
- }
-
- /** Performs the upload and returns true if successful. */
- private boolean tryUploading(CoverageFile coverageFile, TeamscaleServer teamscaleServer) {
- logger.debug("Uploading JaCoCo artifact to {}", teamscaleServer);
-
- try {
- // Cannot be executed in the constructor as this causes issues in WildFly server
- // (See #100)
- ITeamscaleService api = TeamscaleServiceGenerator.createService(ITeamscaleService.class,
- teamscaleServer.url, teamscaleServer.userName, teamscaleServer.userAccessToken,
- AgentUtils.USER_AGENT);
- ITeamscaleServiceKt.uploadReport(api, teamscaleServer.project, teamscaleServer.commit,
- teamscaleServer.revision,
- teamscaleServer.repository, teamscaleServer.partition, EReportFormat.JACOCO,
- teamscaleServer.getMessage(), coverageFile.createFormRequestBody());
- return true;
- } catch (IOException e) {
- logger.error("Failed to upload coverage to {}", teamscaleServer, e);
- return false;
- }
- }
-
- @Override
- public String describe() {
- return "Uploading to " + teamscaleServer;
- }
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/util/AgentUtils.java b/agent/src/main/java/com/teamscale/jacoco/agent/util/AgentUtils.java
deleted file mode 100644
index e055ee8c1..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/util/AgentUtils.java
+++ /dev/null
@@ -1,67 +0,0 @@
-package com.teamscale.jacoco.agent.util;
-
-import com.teamscale.client.FileSystemUtils;
-import com.teamscale.client.TeamscaleServiceGenerator;
-import com.teamscale.jacoco.agent.PreMain;
-import com.teamscale.jacoco.agent.configuration.ProcessInformationRetriever;
-
-import java.io.IOException;
-import java.net.URI;
-import java.net.URISyntaxException;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.nio.file.Paths;
-import java.util.ResourceBundle;
-
-/** General utilities for working with the agent. */
-public class AgentUtils {
-
- /** Version of this program. */
- public static final String VERSION;
-
- /** User-Agent header value for HTTP requests. */
- public static final String USER_AGENT;
-
- private static Path mainTempDirectory = null;
-
- static {
- ResourceBundle bundle = ResourceBundle.getBundle("com.teamscale.jacoco.agent.app");
- VERSION = bundle.getString("version");
- USER_AGENT = TeamscaleServiceGenerator.buildUserAgent("Teamscale Java Profiler", VERSION);
- }
-
- /**
- * Returns the main temporary directory where all agent temp files should be placed.
- */
- public static Path getMainTempDirectory() {
- if (mainTempDirectory == null) {
- try {
- // We add a trailing hyphen here to visually separate the PID from the random number that Java appends
- // to the name to make it unique
- mainTempDirectory = Files.createTempDirectory("teamscale-java-profiler-" +
- FileSystemUtils.toSafeFilename(ProcessInformationRetriever.getPID()) + "-");
- } catch (IOException e) {
- throw new RuntimeException("Failed to create temporary directory for agent files", e);
- }
- }
- return mainTempDirectory;
- }
-
- /** Returns the directory that contains the agent installation. */
- public static Path getAgentDirectory() {
- try {
- URI jarFileUri = PreMain.class.getProtectionDomain().getCodeSource().getLocation().toURI();
- // we assume that the dist zip is extracted and the agent jar not moved
- Path jarDirectory = Paths.get(jarFileUri).getParent();
- Path installDirectory = jarDirectory.getParent();
- if (installDirectory == null) {
- // happens when the jar file is stored in the root directory
- return jarDirectory;
- }
- return installDirectory;
- } catch (URISyntaxException e) {
- throw new RuntimeException("Failed to obtain agent directory. This is a bug, please report it.", e);
- }
- }
-
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/util/Assertions.java b/agent/src/main/java/com/teamscale/jacoco/agent/util/Assertions.java
deleted file mode 100644
index b789c5f6a..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/util/Assertions.java
+++ /dev/null
@@ -1,46 +0,0 @@
-package com.teamscale.jacoco.agent.util;
-
-import org.jetbrains.annotations.Contract;
-
-/**
- * Simple methods to implement assertions.
- */
-public class Assertions {
-
- /**
- * Checks if a condition is true.
- *
- * @param condition condition to check
- * @param message exception message
- * @throws AssertionError if the condition is false
- */
- @Contract(value = "false, _ -> fail", pure = true)
- public static void isTrue(boolean condition, String message) throws AssertionError {
- throwAssertionErrorIfTestFails(condition, message);
- }
-
- /**
- * Checks if a condition is false.
- *
- * @param condition condition to check
- * @param message exception message
- * @throws AssertionError if the condition is true
- */
- @Contract(value = "true, _ -> fail", pure = true)
- public static void isFalse(boolean condition, String message) throws AssertionError {
- throwAssertionErrorIfTestFails(!condition, message);
- }
-
- /**
- * Throws an {@link AssertionError} if the test fails.
- *
- * @param test test which should be true
- * @param message exception message
- * @throws AssertionError if the test fails
- */
- private static void throwAssertionErrorIfTestFails(boolean test, String message) {
- if (!test) {
- throw new AssertionError(message);
- }
- }
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/util/Benchmark.java b/agent/src/main/java/com/teamscale/jacoco/agent/util/Benchmark.java
deleted file mode 100644
index 64f95652b..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/util/Benchmark.java
+++ /dev/null
@@ -1,35 +0,0 @@
-package com.teamscale.jacoco.agent.util;
-
-import com.teamscale.jacoco.agent.logging.LoggingUtils;
-import org.slf4j.Logger;
-
-/**
- * Measures how long a certain piece of code takes and logs it to the debug log.
- *
- * Use this in a try-with-resources. Time measurement starts when the resource
- * is created and ends when it is closed.
- */
-public class Benchmark implements AutoCloseable {
-
- /** The logger. */
- private final Logger logger = LoggingUtils.getLogger(this);
-
- /** The time when the resource was created. */
- private final long startTime;
-
- /** The description to use in the log message. */
- private String description;
-
- /** Constructor. */
- public Benchmark(String description) {
- this.description = description;
- startTime = System.nanoTime();
- }
-
- /** {@inheritDoc} */
- @Override
- public void close() {
- long endTime = System.nanoTime();
- logger.debug("{} took {}s", description, (endTime - startTime) / 1_000_000_000L);
- }
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/util/DaemonThreadFactory.java b/agent/src/main/java/com/teamscale/jacoco/agent/util/DaemonThreadFactory.java
deleted file mode 100644
index 1ec3461ec..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/util/DaemonThreadFactory.java
+++ /dev/null
@@ -1,22 +0,0 @@
-package com.teamscale.jacoco.agent.util;
-
-import java.util.concurrent.ThreadFactory;
-
-/**
- * {@link ThreadFactory} that only produces deamon threads (threads that don't prevent JVM shutdown) with a fixed name.
- */
-public class DaemonThreadFactory implements ThreadFactory {
-
- private final String threadName;
-
- public DaemonThreadFactory(Class> owningClass, String threadName) {
- this.threadName = "Teamscale Java Profiler " + owningClass.getSimpleName() + " " + threadName;
- }
-
- @Override
- public Thread newThread(Runnable runnable) {
- Thread thread = new Thread(runnable, threadName);
- thread.setDaemon(true);
- return thread;
- }
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/util/NullOutputStream.java b/agent/src/main/java/com/teamscale/jacoco/agent/util/NullOutputStream.java
deleted file mode 100644
index 856e316a1..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/util/NullOutputStream.java
+++ /dev/null
@@ -1,29 +0,0 @@
-package com.teamscale.jacoco.agent.util;
-
-import org.jetbrains.annotations.NotNull;
-
-import java.io.IOException;
-import java.io.OutputStream;
-
-/** NOP output stream implementation. */
-public class NullOutputStream extends OutputStream {
-
- public NullOutputStream() {
- // do nothing
- }
-
- @Override
- public void write(final byte @NotNull [] b, final int off, final int len) {
- // to /dev/null
- }
-
- @Override
- public void write(final int b) {
- // to /dev/null
- }
-
- @Override
- public void write(final byte @NotNull [] b) throws IOException {
- // to /dev/null
- }
-}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/util/Timer.java b/agent/src/main/java/com/teamscale/jacoco/agent/util/Timer.java
deleted file mode 100644
index a6787e085..000000000
--- a/agent/src/main/java/com/teamscale/jacoco/agent/util/Timer.java
+++ /dev/null
@@ -1,59 +0,0 @@
-/*-------------------------------------------------------------------------+
-| |
-| Copyright (c) 2009-2018 CQSE GmbH |
-| |
-+-------------------------------------------------------------------------*/
-package com.teamscale.jacoco.agent.util;
-
-import java.time.Duration;
-import java.util.concurrent.Executors;
-import java.util.concurrent.ScheduledFuture;
-import java.util.concurrent.ScheduledThreadPoolExecutor;
-import java.util.concurrent.TimeUnit;
-
-/**
- * Triggers a callback in a regular interval. Note that the spawned threads are
- * Daemon threads, i.e. they will not prevent the JVM from shutting down.
- *
- * The timer will abort if the given {@link #runnable} ever throws an exception.
- */
-public class Timer {
-
- /** Runs the job on a background daemon thread. */
- private final ScheduledThreadPoolExecutor executor = new ScheduledThreadPoolExecutor(1, runnable -> {
- Thread thread = Executors.defaultThreadFactory().newThread(runnable);
- thread.setDaemon(true);
- return thread;
- });
-
- /** The currently running job or null. */
- private ScheduledFuture> job = null;
-
- /** The job to execute periodically. */
- private final Runnable runnable;
-
- /** Duration between two job executions. */
- private final Duration duration;
-
- /** Constructor. */
- public Timer(Runnable runnable, Duration duration) {
- this.runnable = runnable;
- this.duration = duration;
- }
-
- /** Starts the regular job. */
- public synchronized void start() {
- if (job != null) {
- return;
- }
-
- job = executor.scheduleAtFixedRate(runnable, duration.toMinutes(), duration.toMinutes(), TimeUnit.MINUTES);
- }
-
- /** Stops the regular job, possibly aborting it. */
- public synchronized void stop() {
- job.cancel(false);
- job = null;
- }
-
-}
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/Agent.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/Agent.kt
new file mode 100644
index 000000000..cd7ac0fdf
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/Agent.kt
@@ -0,0 +1,170 @@
+package com.teamscale.jacoco.agent
+
+import com.teamscale.client.FileSystemUtils
+import com.teamscale.client.StringUtils
+import com.teamscale.jacoco.agent.logging.LoggingUtils
+import com.teamscale.jacoco.agent.options.AgentOptions
+import com.teamscale.jacoco.agent.upload.IUploadRetry
+import com.teamscale.jacoco.agent.upload.IUploader
+import com.teamscale.jacoco.agent.upload.teamscale.TeamscaleUploader
+import com.teamscale.jacoco.agent.util.AgentUtils
+import com.teamscale.report.jacoco.CoverageFile
+import com.teamscale.report.jacoco.EmptyReportException
+import com.teamscale.report.jacoco.JaCoCoXmlReportGenerator
+import com.teamscale.report.jacoco.dump.Dump
+import org.glassfish.jersey.server.ResourceConfig
+import org.glassfish.jersey.server.ServerProperties
+import java.io.File
+import java.io.IOException
+import java.lang.instrument.Instrumentation
+import java.nio.file.Files
+import java.util.Timer
+import kotlin.concurrent.fixedRateTimer
+import kotlin.io.path.deleteIfExists
+import kotlin.io.path.listDirectoryEntries
+import kotlin.time.DurationUnit
+import kotlin.time.toDuration
+
+/**
+ * A wrapper around the JaCoCo Java agent that automatically triggers a dump and XML conversion based on a time
+ * interval.
+ */
+class Agent(options: AgentOptions, instrumentation: Instrumentation?) : AgentBase(options) {
+ /** Converts binary data to XML. */
+ private val generator: JaCoCoXmlReportGenerator
+
+ /** Regular dump task. */
+ private var timer: Timer? = null
+
+ /** Stores the XML files. */
+ private val uploader = options.createUploader(instrumentation)
+
+ /** Constructor. */
+ init {
+ logger.info("Upload method: {}", uploader.describe())
+ retryUnsuccessfulUploads(options, uploader)
+ generator = JaCoCoXmlReportGenerator(
+ options.classDirectoriesOrZips,
+ options.locationIncludeFilter,
+ options.duplicateClassFileBehavior,
+ options.ignoreUncoveredClasses,
+ LoggingUtils.wrap(logger)
+ )
+
+ if (options.shouldDumpInIntervals()) {
+ val period = options.dumpIntervalInMinutes.toDuration(DurationUnit.MINUTES).inWholeMilliseconds
+ timer = fixedRateTimer("Teamscale-Java-Profiler", true, period, period) {
+ dumpReport()
+ }
+ logger.info("Dumping every ${options.dumpIntervalInMinutes} minutes.")
+ }
+ options.teamscaleServer.partition?.let { partition ->
+ controller.sessionId = partition
+ }
+ }
+
+ /**
+ * If we have coverage that was leftover because of previously unsuccessful coverage uploads, we retry to upload
+ * them again with the same configuration as in the previous try.
+ */
+ private fun retryUnsuccessfulUploads(options: AgentOptions, uploader: IUploader) {
+ var outputPath = options.outputDirectory
+ if (outputPath == null) {
+ // Default fallback
+ outputPath = AgentUtils.agentDirectory.resolve("coverage")
+ }
+
+ val parentPath = outputPath.parent
+ if (parentPath == null) {
+ logger.error("The output path '{}' does not have a parent path. Canceling upload retry.", outputPath.toAbsolutePath())
+ return
+ }
+
+ parentPath.toFile().walk()
+ .filter { it.name.endsWith(TeamscaleUploader.RETRY_UPLOAD_FILE_SUFFIX) }
+ .forEach { file ->
+ reuploadCoverageFromPropertiesFile(file, uploader)
+ }
+ }
+
+ private fun reuploadCoverageFromPropertiesFile(file: File, uploader: IUploader) {
+ logger.info("Retrying previously unsuccessful coverage upload for file {}.", file)
+ try {
+ val properties = FileSystemUtils.readProperties(file)
+ val coverageFile = CoverageFile(
+ File(StringUtils.stripSuffix(file.absolutePath, TeamscaleUploader.RETRY_UPLOAD_FILE_SUFFIX))
+ )
+
+ if (uploader is IUploadRetry) {
+ uploader.reupload(coverageFile, properties)
+ } else {
+ logger.info("Reupload not implemented for uploader {}", uploader.describe())
+ }
+ Files.deleteIfExists(file.toPath())
+ } catch (e: IOException) {
+ logger.error("Reuploading coverage failed. $e")
+ }
+ }
+
+ override fun initResourceConfig(): ResourceConfig? {
+ val resourceConfig = ResourceConfig()
+ resourceConfig.property(ServerProperties.WADL_FEATURE_DISABLE, true.toString())
+ AgentResource.setAgent(this)
+ return resourceConfig.register(AgentResource::class.java).register(GenericExceptionMapper::class.java)
+ }
+
+ override fun prepareShutdown() {
+ timer?.cancel()
+ if (options.shouldDumpOnExit) dumpReport()
+
+ val dir = options.outputDirectory ?: return
+ try {
+ if (dir.listDirectoryEntries().isEmpty()) dir.deleteIfExists()
+ } catch (e: IOException) {
+ logger.info(
+ ("Could not delete empty output directory {}. "
+ + "This directory was created inside the configured output directory to be able to "
+ + "distinguish between different runs of the profiled JVM. You may delete it manually."),
+ dir, e
+ )
+ }
+ }
+
+ /**
+ * Dumps the current execution data, converts it, writes it to the output directory defined in [.options] and
+ * uploads it if an uploader is configured. Logs any errors, never throws an exception.
+ */
+ override fun dumpReport() {
+ logger.debug("Starting dump")
+
+ try {
+ dumpReportUnsafe()
+ } catch (t: Throwable) {
+ // we want to catch anything in order to avoid crashing the whole system under
+ // test
+ logger.error("Dump job failed with an exception", t)
+ }
+ }
+
+ private fun dumpReportUnsafe() {
+ val dump: Dump
+ try {
+ dump = controller.dumpAndReset()
+ } catch (e: JacocoRuntimeController.DumpException) {
+ logger.error("Dumping failed, retrying later", e)
+ return
+ }
+
+ try {
+ benchmark("Generating the XML report") {
+ val outputFile = options.createNewFileInOutputDirectory("jacoco", "xml")
+ val coverageFile = generator.convertSingleDumpToReport(dump, outputFile)
+ uploader.upload(coverageFile)
+ }
+ } catch (e: IOException) {
+ logger.error("Converting binary dump to XML failed", e)
+ } catch (e: EmptyReportException) {
+ logger.error("No coverage was collected. ${e.message}", e)
+ }
+ }
+}
\ No newline at end of file
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/AgentBase.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/AgentBase.kt
new file mode 100644
index 000000000..3cfaf93f3
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/AgentBase.kt
@@ -0,0 +1,153 @@
+package com.teamscale.jacoco.agent
+
+import com.teamscale.jacoco.agent.logging.LoggingUtils
+import com.teamscale.jacoco.agent.options.AgentOptions
+import org.eclipse.jetty.server.Server
+import org.eclipse.jetty.server.ServerConnector
+import org.eclipse.jetty.servlet.ServletContextHandler
+import org.eclipse.jetty.servlet.ServletHolder
+import org.eclipse.jetty.util.thread.QueuedThreadPool
+import org.glassfish.jersey.server.ResourceConfig
+import org.glassfish.jersey.servlet.ServletContainer
+import org.jacoco.agent.rt.RT
+import org.slf4j.Logger
+import java.lang.management.ManagementFactory
+
+/**
+ * Base class for agent implementations. Handles logger shutdown, store creation and instantiation of the
+ * [JacocoRuntimeController].
+ *
+ *
+ * Subclasses must handle dumping onto disk and uploading via the configured uploader.
+ */
+abstract class AgentBase(
+ /** The agent options. */
+ @JvmField var options: AgentOptions
+) {
+ /** The logger. */
+ val logger: Logger = LoggingUtils.getLogger(this)
+
+ /** Controls the JaCoCo runtime. */
+ @JvmField
+ val controller: JacocoRuntimeController
+
+ private lateinit var server: Server
+
+ /**
+ * Lazily generated string representation of the command line arguments to print to the log.
+ */
+ private val optionsObjectToLog by lazy {
+ object {
+ override fun toString(): String =
+ if (options.obfuscateSecurityRelatedOutputs) {
+ options.obfuscatedOptionsString
+ } else {
+ options.originalOptionsString
+ } ?: ""
+ }
+ }
+
+ init {
+ try {
+ controller = JacocoRuntimeController(RT.getAgent())
+ } catch (e: IllegalStateException) {
+ throw IllegalStateException("Teamscale Java Profiler not started or there is a conflict with another agent on the classpath.", e)
+ }
+ logger.info(
+ "Starting Teamscale Java Profiler for process {} with options: {}",
+ ManagementFactory.getRuntimeMXBean().name, optionsObjectToLog
+ )
+ options.httpServerPort?.let { port ->
+ try {
+ initServer()
+ } catch (e: Exception) {
+ logger.error("Could not start http server on port $port. Please check if the port is blocked.")
+ throw IllegalStateException("Control server not started.", e)
+ }
+ }
+ }
+
+ /**
+ * Starts the http server, which waits for information about started and finished tests.
+ */
+ @Throws(Exception::class)
+ private fun initServer() {
+ val port = options.httpServerPort
+ require(port != null) { "Port must be set." }
+
+ logger.info("Listening for test events on port {}.", port)
+
+ // Jersey Implementation
+ val handler = buildUsingResourceConfig()
+ val threadPool = QueuedThreadPool()
+ threadPool.maxThreads = 10
+ threadPool.isDaemon = true
+
+ // Create a server instance and set the thread pool
+ server = Server(threadPool)
+ // Create a server connector, set the port and add it to the server
+ val connector = ServerConnector(server)
+ connector.port = port
+ server.addConnector(connector)
+ server.handler = handler
+ server.start()
+ }
+
+ private fun buildUsingResourceConfig(): ServletContextHandler {
+ val handler = ServletContextHandler(ServletContextHandler.NO_SESSIONS)
+ handler.contextPath = "/"
+
+ val resourceConfig = initResourceConfig()
+ handler.addServlet(ServletHolder(ServletContainer(resourceConfig)), "/*")
+ return handler
+ }
+
+ /**
+ * Initializes the [ResourceConfig] needed for the Jetty + Jersey Server
+ */
+ protected abstract fun initResourceConfig(): ResourceConfig?
+
+ /**
+ * Registers a shutdown hook that stops the timer and dumps coverage a final time.
+ */
+ fun registerShutdownHook() {
+ Runtime.getRuntime().addShutdownHook(Thread {
+ try {
+ logger.info("Teamscale Java Profiler is shutting down...")
+ stopServer()
+ prepareShutdown()
+ logger.info("Teamscale Java Profiler successfully shut down.")
+ } catch (e: Exception) {
+ logger.error("Exception during profiler shutdown.", e)
+ } finally {
+ // Try to flush logging resources also in case of an exception during shutdown
+ PreMain.closeLoggingResources()
+ }
+ })
+ }
+
+ /** Stop the http server if it's running */
+ fun stopServer() {
+ options.httpServerPort?.let {
+ try {
+ server.stop()
+ } catch (e: Exception) {
+ logger.error("Could not stop server so it is killed now.", e)
+ } finally {
+ server.destroy()
+ }
+ }
+ }
+
+ /** Called when the shutdown hook is triggered. */
+ protected open fun prepareShutdown() {
+ // Template method to be overridden by subclasses.
+ }
+
+ /**
+ * Dumps the current execution data, converts it, writes it to the output
+ * directory defined in [.options] and uploads it if an uploader is
+ * configured. Logs any errors, never throws an exception.
+ */
+ abstract fun dumpReport()
+}
\ No newline at end of file
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/AgentResource.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/AgentResource.kt
new file mode 100644
index 000000000..94447f400
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/AgentResource.kt
@@ -0,0 +1,41 @@
+package com.teamscale.jacoco.agent
+
+import javax.ws.rs.POST
+import javax.ws.rs.Path
+import javax.ws.rs.core.Response
+
+/**
+ * The resource of the Jersey + Jetty http server holding all the endpoints specific for the [Agent].
+ */
+@Path("/")
+class AgentResource : ResourceBase() {
+ /** Handles dumping a XML coverage report for coverage collected until now. */
+ @POST
+ @Path("/dump")
+ fun handleDump(): Response? {
+ logger.debug("Dumping report triggered via HTTP request")
+ agent.dumpReport()
+ return Response.noContent().build()
+ }
+
+ /** Handles resetting of coverage. */
+ @POST
+ @Path("/reset")
+ fun handleReset(): Response? {
+ logger.debug("Resetting coverage triggered via HTTP request")
+ agent.controller.reset()
+ return Response.noContent().build()
+ }
+
+ companion object {
+ private lateinit var agent: Agent
+
+ /**
+ * Static setter to inject the [Agent] to the resource.
+ */
+ fun setAgent(agent: Agent) {
+ Companion.agent = agent
+ agentBase = agent
+ }
+ }
+}
\ No newline at end of file
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/DelayedLogger.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/DelayedLogger.kt
new file mode 100644
index 000000000..d242ff79d
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/DelayedLogger.kt
@@ -0,0 +1,52 @@
+package com.teamscale.jacoco.agent
+
+import com.teamscale.report.util.ILogger
+import org.slf4j.Logger
+import java.util.function.Consumer
+
+/**
+ * A logger that buffers logs in memory and writes them to the actual logger at a later point. This is needed when stuff
+ * needs to be logged before the actual logging framework is initialized.
+ */
+class DelayedLogger : ILogger {
+ /** List of log actions that will be executed once the logger is initialized. */
+ private val logActions = mutableListOf Unit>()
+
+ override fun debug(message: String) {
+ logActions.add { debug(message) }
+ }
+
+ override fun info(message: String) {
+ logActions.add { info(message) }
+ }
+
+ override fun warn(message: String) {
+ logActions.add { warn(message) }
+ }
+
+ override fun warn(message: String, throwable: Throwable?) {
+ logActions.add { warn(message, throwable) }
+ }
+
+ override fun error(throwable: Throwable) {
+ logActions.add { error(throwable.message, throwable) }
+ }
+
+ override fun error(message: String, throwable: Throwable?) {
+ logActions.add { error(message, throwable) }
+ }
+
+ /**
+ * Logs an error and also writes the message to [System.err] to ensure the message is even logged in case
+ * setting up the logger itself fails for some reason (see TS-23151).
+ */
+ fun errorAndStdErr(message: String?, throwable: Throwable?) {
+ System.err.println(message)
+ logActions.add { error(message, throwable) }
+ }
+
+ /** Writes the logs to the given slf4j logger. */
+ fun logTo(logger: Logger) {
+ logActions.forEach { action -> action(logger) }
+ }
+}
\ No newline at end of file
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/GenericExceptionMapper.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/GenericExceptionMapper.kt
new file mode 100644
index 000000000..c7f0f9909
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/GenericExceptionMapper.kt
@@ -0,0 +1,18 @@
+package com.teamscale.jacoco.agent
+
+import javax.ws.rs.core.MediaType
+import javax.ws.rs.core.Response
+import javax.ws.rs.ext.ExceptionMapper
+import javax.ws.rs.ext.Provider
+
+/**
+ * Generates a [javax.ws.rs.core.Response] for an exception.
+ */
+@Provider
+class GenericExceptionMapper : ExceptionMapper {
+ override fun toResponse(e: Throwable?): Response =
+ Response.status(Response.Status.INTERNAL_SERVER_ERROR).apply {
+ type(MediaType.TEXT_PLAIN_TYPE)
+ entity("Message: ${e?.message}")
+ }.build()
+}
\ No newline at end of file
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/Helpers.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/Helpers.kt
new file mode 100644
index 000000000..d3dc43a97
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/Helpers.kt
@@ -0,0 +1,6 @@
+package com.teamscale.jacoco.agent
+
+import kotlin.time.measureTime
+
+fun benchmark(name: String, action: () -> Unit) =
+ measureTime { action() }.also { duration -> Main.logger.debug("$name took $duration") }
\ No newline at end of file
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/JacocoRuntimeController.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/JacocoRuntimeController.kt
new file mode 100644
index 000000000..0a76c0d3e
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/JacocoRuntimeController.kt
@@ -0,0 +1,118 @@
+package com.teamscale.jacoco.agent
+
+import com.teamscale.report.jacoco.dump.Dump
+import org.jacoco.agent.rt.IAgent
+import org.jacoco.core.data.ExecutionData
+import org.jacoco.core.data.ExecutionDataReader
+import org.jacoco.core.data.ExecutionDataStore
+import org.jacoco.core.data.IExecutionDataVisitor
+import org.jacoco.core.data.ISessionInfoVisitor
+import org.jacoco.core.data.SessionInfo
+import java.io.ByteArrayInputStream
+import java.io.File
+import java.io.FileOutputStream
+import java.io.IOException
+
+/**
+ * Wrapper around JaCoCo's [RT] runtime interface.
+ *
+ *
+ * Can be used if the calling code is run in the same JVM as the agent is attached to.
+ */
+class JacocoRuntimeController
+/** Constructor. */(
+ /** JaCoCo's [RT] agent instance */
+ private val agent: IAgent
+) {
+ /** Indicates a failed dump. */
+ class DumpException(message: String?, cause: Throwable?) : Exception(message, cause)
+
+ /**
+ * Dumps execution data and resets it.
+ *
+ * @throws DumpException if dumping fails. This should never happen in real life. Dumping should simply be retried
+ * later if this ever happens.
+ */
+ @Throws(DumpException::class)
+ fun dumpAndReset(): Dump {
+ val binaryData = agent.getExecutionData(true)
+
+ try {
+ ByteArrayInputStream(binaryData).use { inputStream ->
+ ExecutionDataReader(inputStream).apply {
+ val store = ExecutionDataStore()
+ setExecutionDataVisitor { store.put(it) }
+ val sessionInfoVisitor = SessionInfoVisitor()
+ setSessionInfoVisitor(sessionInfoVisitor)
+ read()
+ return Dump(sessionInfoVisitor.sessionInfo, store)
+ }
+ }
+ } catch (e: IOException) {
+ throw DumpException("should never happen for the ByteArrayInputStream", e)
+ }
+ }
+
+ /**
+ * Dumps execution data to the given file and resets it afterwards.
+ */
+ @Throws(IOException::class)
+ fun dumpToFileAndReset(file: File) {
+ val binaryData = agent.getExecutionData(true)
+
+ FileOutputStream(file, true).use { outputStream ->
+ outputStream.write(binaryData)
+ }
+ }
+
+
+ /**
+ * Dumps execution data to a file and resets it.
+ *
+ * @throws DumpException if dumping fails. This should never happen in real life. Dumping should simply be retried
+ * later if this ever happens.
+ */
+ @Throws(DumpException::class)
+ fun dump() {
+ try {
+ agent.dump(true)
+ } catch (e: IOException) {
+ throw DumpException(e.message, e)
+ }
+ }
+
+ /** Resets already collected coverage. */
+ fun reset() {
+ agent.reset()
+ }
+
+ var sessionId: String?
+ /** Returns the current sessionId. */
+ get() = agent.sessionId
+ /**
+ * Sets the current sessionId of the agent that can be used to identify which coverage is recorded from now on.
+ */
+ set(sessionId) {
+ agent.setSessionId(sessionId)
+ }
+
+ /** Unsets the session ID so that coverage collected from now on is not attributed to the previous test. */
+ fun resetSessionId() {
+ agent.sessionId = ""
+ }
+
+ /**
+ * Receives and stores a [org.jacoco.core.data.SessionInfo]. Has a fallback dummy session in case nothing is received.
+ */
+ private class SessionInfoVisitor : ISessionInfoVisitor {
+ /** The received session info or a dummy. */
+ var sessionInfo: SessionInfo = SessionInfo(
+ "dummysession", System.currentTimeMillis(), System.currentTimeMillis()
+ )
+
+ /** {@inheritDoc} */
+ override fun visitSessionInfo(info: SessionInfo) {
+ this.sessionInfo = info
+ }
+ }
+}
\ No newline at end of file
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/LenientCoverageTransformer.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/LenientCoverageTransformer.kt
new file mode 100644
index 000000000..dd9094e71
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/LenientCoverageTransformer.kt
@@ -0,0 +1,51 @@
+package com.teamscale.jacoco.agent
+
+import org.jacoco.agent.rt.internal_29a6edd.CoverageTransformer
+import org.jacoco.agent.rt.internal_29a6edd.IExceptionLogger
+import org.jacoco.agent.rt.internal_29a6edd.core.runtime.AgentOptions
+import org.jacoco.agent.rt.internal_29a6edd.core.runtime.IRuntime
+import org.slf4j.Logger
+import java.lang.instrument.IllegalClassFormatException
+import java.security.ProtectionDomain
+
+/**
+ * A class file transformer which delegates to the JaCoCo [org.jacoco.agent.rt.internal_29a6edd.CoverageTransformer] to do the actual instrumentation,
+ * but treats instrumentation errors e.g. due to unsupported class file versions more lenient by only logging them, but
+ * not bailing out completely. Those unsupported classes will not be instrumented and will therefore not be contained in
+ * the collected coverage report.
+ */
+class LenientCoverageTransformer(
+ runtime: IRuntime?,
+ options: AgentOptions,
+ private val logger: Logger
+) : CoverageTransformer(
+ runtime,
+ options,
+ // The coverage transformer only uses the logger to print an error when the instrumentation fails.
+ // We want to show our more specific error message instead, so we only log this for debugging at trace.
+ IExceptionLogger { logger.trace(it.message, it) }
+) {
+ override fun transform(
+ loader: ClassLoader?,
+ classname: String,
+ classBeingRedefined: Class<*>?,
+ protectionDomain: ProtectionDomain?,
+ classfileBuffer: ByteArray
+ ): ByteArray? {
+ try {
+ return super.transform(loader, classname, classBeingRedefined, protectionDomain, classfileBuffer)
+ } catch (e: IllegalClassFormatException) {
+ logger.error(
+ "Failed to instrument $classname. File will be skipped from instrumentation. " +
+ "No coverage will be collected for it. Exclude the file from the instrumentation or try " +
+ "updating the Teamscale Java Profiler if the file should actually be instrumented. (Cause: ${getRootCauseMessage(e)})"
+ )
+ return null
+ }
+ }
+
+ companion object {
+ private fun getRootCauseMessage(e: Throwable): String? =
+ e.cause?.let { getRootCauseMessage(it) } ?: e.message
+ }
+}
\ No newline at end of file
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/Main.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/Main.kt
new file mode 100644
index 000000000..e1efc8a12
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/Main.kt
@@ -0,0 +1,81 @@
+package com.teamscale.jacoco.agent
+
+import com.beust.jcommander.JCommander
+import com.beust.jcommander.Parameter
+import com.beust.jcommander.ParameterException
+import com.teamscale.client.StringUtils
+import com.teamscale.jacoco.agent.convert.ConvertCommand
+import com.teamscale.jacoco.agent.logging.LoggingUtils
+import com.teamscale.jacoco.agent.util.AgentUtils
+import org.jacoco.core.JaCoCo
+import org.slf4j.Logger
+import kotlin.system.exitProcess
+
+/** Provides a command line interface for interacting with JaCoCo. */
+object Main {
+ /** The logger. */
+ val logger: Logger = LoggingUtils.getLogger(this)
+
+ /** The default arguments that will always be parsed. */
+ private val defaultArguments = DefaultArguments()
+
+ /** The arguments for the one-time conversion process. */
+ private val command = ConvertCommand()
+
+ /** Entry point. */
+ @Throws(Exception::class)
+ @JvmStatic
+ fun main(args: Array) {
+ parseCommandLineAndRun(args)
+ }
+
+ /**
+ * Parses the given command line arguments. Exits the program or throws an exception if the arguments are not valid.
+ * Then runs the specified command.
+ */
+ @Throws(Exception::class)
+ private fun parseCommandLineAndRun(args: Array) {
+ val builder = createJCommanderBuilder()
+ val jCommander = builder.build()
+
+ try {
+ jCommander.parse(*args)
+ } catch (e: ParameterException) {
+ handleInvalidCommandLine(jCommander, e.message)
+ }
+
+ if (defaultArguments.help) {
+ println("Teamscale Java Profiler ${AgentUtils.VERSION} compiled against JaCoCo ${JaCoCo.VERSION}")
+ jCommander.usage()
+ return
+ }
+
+ val validator = command.validate()
+ if (!validator.isValid) {
+ handleInvalidCommandLine(jCommander, StringUtils.LINE_FEED + validator.errorMessage)
+ }
+
+ logger.info("Starting Teamscale Java Profiler ${AgentUtils.VERSION} compiled against JaCoCo ${JaCoCo.VERSION}")
+ command.run()
+ }
+
+ /** Shows an informative error and help message. Then exits the program. */
+ private fun handleInvalidCommandLine(jCommander: JCommander, message: String?) {
+ System.err.println("Invalid command line: $message${StringUtils.LINE_FEED}")
+ jCommander.usage()
+ exitProcess(1)
+ }
+
+ /** Creates a builder for a [com.beust.jcommander.JCommander] object. */
+ private fun createJCommanderBuilder() =
+ JCommander.newBuilder().programName(Main::class.java.getName())
+ .addObject(defaultArguments)
+ .addObject(command)
+
+ /** Default arguments that may always be provided. */
+ private class DefaultArguments {
+ /** Shows the help message. */
+ @Parameter(names = ["--help"], help = true, description = "Shows all available command line arguments.")
+ val help = false
+ }
+}
\ No newline at end of file
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/PreMain.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/PreMain.kt
new file mode 100644
index 000000000..c4cbd4ffa
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/PreMain.kt
@@ -0,0 +1,306 @@
+package com.teamscale.jacoco.agent
+
+import com.teamscale.client.FileSystemUtils
+import com.teamscale.client.HttpUtils
+import com.teamscale.client.StringUtils
+import com.teamscale.jacoco.agent.configuration.AgentOptionReceiveException
+import com.teamscale.jacoco.agent.logging.DebugLogDirectoryPropertyDefiner
+import com.teamscale.jacoco.agent.logging.LogDirectoryPropertyDefiner
+import com.teamscale.jacoco.agent.logging.LogToTeamscaleAppender
+import com.teamscale.jacoco.agent.logging.LoggingUtils
+import com.teamscale.jacoco.agent.options.AgentOptionParseException
+import com.teamscale.jacoco.agent.options.AgentOptions
+import com.teamscale.jacoco.agent.options.AgentOptionsParser
+import com.teamscale.jacoco.agent.options.FilePatternResolver
+import com.teamscale.jacoco.agent.options.JacocoAgentOptionsBuilder
+import com.teamscale.jacoco.agent.options.TeamscalePropertiesUtils
+import com.teamscale.jacoco.agent.testimpact.TestwiseCoverageAgent
+import com.teamscale.jacoco.agent.upload.UploaderException
+import com.teamscale.jacoco.agent.util.AgentUtils
+import com.teamscale.report.util.ILogger
+import java.io.IOException
+import java.lang.instrument.Instrumentation
+import java.lang.management.ManagementFactory
+import java.nio.file.Files
+import java.nio.file.Path
+import java.nio.file.Paths
+import kotlin.use
+
+/** Container class for the premain entry point for the agent. */
+object PreMain {
+ private lateinit var loggingResources: LoggingUtils.LoggingResources
+
+ /**
+ * System property that we use to prevent this agent from being attached to the same VM twice. This can happen if
+ * the agent is registered via multiple JVM environment variables and/or the command line at the same time.
+ */
+ private const val LOCKING_SYSTEM_PROPERTY = "TEAMSCALE_JAVA_PROFILER_ATTACHED"
+
+ /**
+ * Environment variable from which to read the config ID to use. This is an ID for a profiler configuration that is
+ * stored in Teamscale.
+ */
+ private const val CONFIG_ID_ENVIRONMENT_VARIABLE = "TEAMSCALE_JAVA_PROFILER_CONFIG_ID"
+
+ /** Environment variable from which to read the config file to use. */
+ private const val CONFIG_FILE_ENVIRONMENT_VARIABLE = "TEAMSCALE_JAVA_PROFILER_CONFIG_FILE"
+
+ /** Environment variable from which to read the Teamscale access token. */
+ private const val ACCESS_TOKEN_ENVIRONMENT_VARIABLE = "TEAMSCALE_ACCESS_TOKEN"
+
+ /**
+ * Entry point for the agent, called by the JVM.
+ */
+ @JvmStatic
+ @Throws(Exception::class)
+ fun premain(options: String, instrumentation: Instrumentation) {
+ if (System.getProperty(LOCKING_SYSTEM_PROPERTY) != null) return
+ System.setProperty(LOCKING_SYSTEM_PROPERTY, "true")
+
+ val environmentConfigId = System.getenv(CONFIG_ID_ENVIRONMENT_VARIABLE)
+ val environmentConfigFile = System.getenv(CONFIG_FILE_ENVIRONMENT_VARIABLE)
+ if (StringUtils.isEmpty(options) && environmentConfigId == null && environmentConfigFile == null) {
+ // profiler was registered globally, and no config was set explicitly by the user, thus ignore this process
+ // and don't profile anything
+ return
+ }
+
+ var agentOptions: AgentOptions? = null
+ try {
+ val parseResult = getAndApplyAgentOptions(
+ options, environmentConfigId, environmentConfigFile
+ )
+ agentOptions = parseResult.first
+
+ // After parsing everything and configuring logging, we now
+ // can throw the caught exceptions.
+ parseResult.second.forEach { exception ->
+ throw exception
+ }
+ } catch (e: AgentOptionParseException) {
+ LoggingUtils.loggerContext.getLogger(PreMain::class.java).error(e.message, e)
+
+ // Flush logs to Teamscale, if configured.
+ closeLoggingResources()
+
+ // Unregister the profiler from Teamscale.
+ agentOptions?.configurationViaTeamscale?.unregisterProfiler()
+
+ throw e
+ } catch (_: AgentOptionReceiveException) {
+ // When Teamscale is not available, we don't want to fail hard to still allow for testing even if no
+ // coverage is collected (see TS-33237)
+ return
+ }
+
+ val logger = LoggingUtils.getLogger(Agent::class.java)
+
+ logger.info("Teamscale Java profiler version ${AgentUtils.VERSION}")
+ logger.info("Starting JaCoCo's agent")
+ val agentBuilder = JacocoAgentOptionsBuilder(agentOptions)
+ JaCoCoPreMain.premain(agentBuilder.createJacocoAgentOptions(), instrumentation, logger)
+
+ agentOptions.configurationViaTeamscale?.startHeartbeatThreadAndRegisterShutdownHook()
+ createAgent(agentOptions, instrumentation).registerShutdownHook()
+ }
+
+ @Throws(AgentOptionParseException::class, IOException::class, AgentOptionReceiveException::class)
+ private fun getAndApplyAgentOptions(
+ options: String,
+ environmentConfigId: String?,
+ environmentConfigFile: String?
+ ): Pair> {
+ val delayedLogger = DelayedLogger()
+ val javaAgents = ManagementFactory.getRuntimeMXBean().inputArguments
+ .filter { it.contains("-javaagent") }
+ // We allow multiple instances of the teamscale-jacoco-agent as we ensure with the #LOCKING_SYSTEM_PROPERTY to only use it once
+ val differentAgents = javaAgents.filter { !it.contains("teamscale-jacoco-agent.jar") }
+
+ if (!differentAgents.isEmpty()) {
+ delayedLogger.warn(
+ "Using multiple java agents could interfere with coverage recording: ${
+ differentAgents.joinToString()
+ }"
+ )
+ }
+ if (!javaAgents.first().contains("teamscale-jacoco-agent.jar")) {
+ delayedLogger.warn("For best results consider registering the Teamscale Java Profiler first.")
+ }
+
+ val credentials = TeamscalePropertiesUtils.parseCredentials()
+ if (credentials == null) {
+ // As many users still don't use the installer based setup, this log message will be shown in almost every log.
+ // We use a debug log, as this message can be confusing for customers that think a teamscale.properties file is synonymous with a config file.
+ delayedLogger.debug(
+ "No explicit teamscale.properties file given. Looking for Teamscale credentials in a config file or via a command line argument. This is expected unless the installer based setup was used."
+ )
+ }
+
+ val environmentAccessToken = System.getenv(ACCESS_TOKEN_ENVIRONMENT_VARIABLE)
+
+ val parseResult: Pair>
+ val agentOptions: AgentOptions
+ try {
+ parseResult = AgentOptionsParser.parse(
+ options, environmentConfigId, environmentConfigFile, credentials, environmentAccessToken, delayedLogger
+ )
+ agentOptions = parseResult.first
+ } catch (e: AgentOptionParseException) {
+ initializeFallbackLogging(options, delayedLogger).use { _ ->
+ delayedLogger.errorAndStdErr("Failed to parse agent options: ${e.message}", e)
+ attemptLogAndThrow(delayedLogger)
+ throw e
+ }
+ } catch (e: AgentOptionReceiveException) {
+ initializeFallbackLogging(options, delayedLogger).use { _ ->
+ delayedLogger.errorAndStdErr("${e.message} The application should start up normally, but NO coverage will be collected! Check the log file for details.", e)
+ attemptLogAndThrow(delayedLogger)
+ throw e
+ }
+ }
+
+ initializeLogging(agentOptions, delayedLogger)
+ val logger = LoggingUtils.getLogger(Agent::class.java)
+ delayedLogger.logTo(logger)
+ HttpUtils.setShouldValidateSsl(agentOptions.validateSsl)
+
+ return parseResult
+ }
+
+ private fun attemptLogAndThrow(delayedLogger: DelayedLogger) {
+ // We perform actual logging output after writing to console to
+ // ensure the console is reached even in case of logging issues
+ // (see TS-23151). We use the Agent class here (same as below)
+ val logger = LoggingUtils.getLogger(Agent::class.java)
+ delayedLogger.logTo(logger)
+ }
+
+ /** Initializes logging during [premain] and also logs the log directory. */
+ @Throws(IOException::class)
+ private fun initializeLogging(agentOptions: AgentOptions, logger: DelayedLogger) {
+ if (agentOptions.isDebugLogging) {
+ initializeDebugLogging(agentOptions, logger)
+ } else {
+ loggingResources = LoggingUtils.initializeLogging(agentOptions.loggingConfig)
+ logger.info("Logging to ${LogDirectoryPropertyDefiner().getPropertyValue()}")
+ }
+
+ if (agentOptions.teamscaleServer.isConfiguredForServerConnection) {
+ if (LogToTeamscaleAppender.addTeamscaleAppenderTo(LoggingUtils.loggerContext, agentOptions)) {
+ logger.info("Logs are being forwarded to Teamscale at ${agentOptions.teamscaleServer.url}")
+ }
+ }
+ }
+
+ /** Closes the opened logging contexts. */
+ fun closeLoggingResources() {
+ loggingResources.close()
+ }
+
+ /**
+ * Returns in instance of the agent that was configured. Either an agent with interval based line-coverage dump or
+ * the HTTP server is used.
+ */
+ @Throws(UploaderException::class, IOException::class)
+ private fun createAgent(
+ agentOptions: AgentOptions,
+ instrumentation: Instrumentation
+ ): AgentBase = if (agentOptions.useTestwiseCoverageMode()) {
+ TestwiseCoverageAgent.create(agentOptions)
+ } else {
+ Agent(agentOptions, instrumentation)
+ }
+
+ /**
+ * Initializes debug logging during [.premain] and also logs the log directory if
+ * given.
+ */
+ private fun initializeDebugLogging(agentOptions: AgentOptions, logger: DelayedLogger) {
+ loggingResources = LoggingUtils.initializeDebugLogging(agentOptions.debugLogDirectory)
+ val logDirectory = Paths.get(DebugLogDirectoryPropertyDefiner().getPropertyValue())
+ if (FileSystemUtils.isValidPath(logDirectory.toString()) && Files.isWritable(logDirectory)) {
+ logger.info("Logging to $logDirectory")
+ } else {
+ logger.warn("Could not create $logDirectory. Logging to console only.")
+ }
+ }
+
+ /**
+ * Initializes fallback logging in case of an error during the parsing of the options to
+ * [premain] (see TS-23151). This tries to extract the logging configuration and use
+ * this and falls back to the default logger.
+ */
+ private fun initializeFallbackLogging(
+ premainOptions: String?,
+ delayedLogger: DelayedLogger
+ ): LoggingUtils.LoggingResources? {
+ if (premainOptions == null) {
+ return LoggingUtils.initializeDefaultLogging()
+ }
+ premainOptions
+ .split(",".toRegex())
+ .dropLastWhile { it.isEmpty() }
+ .forEach { optionPart ->
+ if (optionPart.startsWith(AgentOptionsParser.DEBUG + "=")) {
+ val value = optionPart.split("=".toRegex(), limit = 2)[1]
+ val debugDisabled = value.equals("false", ignoreCase = true)
+ val debugEnabled = value.equals("true", ignoreCase = true)
+ if (debugDisabled) return@forEach
+ var debugLogDirectory: Path? = null
+ if (!value.isEmpty() && !debugEnabled) {
+ debugLogDirectory = Paths.get(value)
+ }
+ return LoggingUtils.initializeDebugLogging(debugLogDirectory)
+ }
+ if (optionPart.startsWith(AgentOptionsParser.LOGGING_CONFIG_OPTION + "=")) {
+ return createFallbackLoggerFromConfig(
+ optionPart.split("=".toRegex(), limit = 2)[1],
+ delayedLogger
+ )
+ }
+
+ if (optionPart.startsWith(AgentOptionsParser.CONFIG_FILE_OPTION + "=")) {
+ val configFileValue = optionPart.split("=".toRegex(), limit = 2)[1]
+ var loggingConfigLine: String? = null
+ try {
+ val configFile = FilePatternResolver(delayedLogger).parsePath(
+ AgentOptionsParser.CONFIG_FILE_OPTION, configFileValue
+ ).toFile()
+ loggingConfigLine = FileSystemUtils.readLinesUTF8(configFile)
+ .firstOrNull { it.startsWith(AgentOptionsParser.LOGGING_CONFIG_OPTION + "=") }
+ } catch (e: IOException) {
+ delayedLogger.error("Failed to load configuration from $configFileValue: ${e.message}", e)
+ }
+ loggingConfigLine?.let { config ->
+ return createFallbackLoggerFromConfig(
+ config.split("=".toRegex(), limit = 2)[1], delayedLogger
+ )
+ }
+ }
+ }
+
+ return LoggingUtils.initializeDefaultLogging()
+ }
+
+ /** Creates a fallback logger using the given config file. */
+ private fun createFallbackLoggerFromConfig(
+ configLocation: String,
+ delayedLogger: ILogger
+ ): LoggingUtils.LoggingResources {
+ try {
+ return LoggingUtils.initializeLogging(
+ FilePatternResolver(delayedLogger).parsePath(
+ AgentOptionsParser.LOGGING_CONFIG_OPTION,
+ configLocation
+ )
+ )
+ } catch (e: IOException) {
+ val message = "Failed to load log configuration from location $configLocation: ${e.message}"
+ delayedLogger.error(message, e)
+ // output the message to console as well, as this might
+ // otherwise not make it to the user
+ System.err.println(message)
+ return LoggingUtils.initializeDefaultLogging()
+ }
+ }
+}
\ No newline at end of file
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/ResourceBase.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/ResourceBase.kt
new file mode 100644
index 000000000..5107dbed4
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/ResourceBase.kt
@@ -0,0 +1,139 @@
+package com.teamscale.jacoco.agent
+
+import com.teamscale.client.CommitDescriptor
+import com.teamscale.client.StringUtils
+import com.teamscale.jacoco.agent.logging.LoggingUtils
+import com.teamscale.report.testwise.model.RevisionInfo
+import org.jetbrains.annotations.Contract
+import org.slf4j.Logger
+import javax.ws.rs.BadRequestException
+import javax.ws.rs.GET
+import javax.ws.rs.PUT
+import javax.ws.rs.Path
+import javax.ws.rs.Produces
+import javax.ws.rs.core.MediaType
+import javax.ws.rs.core.Response
+
+/**
+ * The resource of the Jersey + Jetty http server holding all the endpoints specific for the [AgentBase].
+ */
+abstract class ResourceBase {
+ /** The logger. */
+ @JvmField
+ protected val logger: Logger = LoggingUtils.getLogger(this)
+
+ companion object {
+ /**
+ * The agentBase inject via [AgentResource.setAgent] or
+ * [com.teamscale.jacoco.agent.testimpact.TestwiseCoverageResource.setAgent].
+ */
+ @JvmStatic
+ protected lateinit var agentBase: AgentBase
+ }
+
+ @get:Path("/partition")
+ @get:GET
+ val partition: String
+ /** Returns the partition for the Teamscale upload. */
+ get() = agentBase.options.teamscaleServer.partition.orEmpty()
+
+ @get:Path("/message")
+ @get:GET
+ val message: String
+ /** Returns the upload message for the Teamscale upload. */
+ get() = agentBase.options.teamscaleServer.message.orEmpty()
+
+ @get:Produces(MediaType.APPLICATION_JSON)
+ @get:Path("/revision")
+ @get:GET
+ val revision: RevisionInfo
+ /** Returns revision information for the Teamscale upload. */
+ get() = revisionInfo
+
+ @get:Produces(MediaType.APPLICATION_JSON)
+ @get:Path("/commit")
+ @get:GET
+ val commit: RevisionInfo
+ /** Returns revision information for the Teamscale upload. */
+ get() = revisionInfo
+
+ /** Handles setting the partition name. */
+ @PUT
+ @Path("/partition")
+ fun setPartition(partitionString: String): Response {
+ val partition = StringUtils.removeDoubleQuotes(partitionString)
+ if (partition.isEmpty()) {
+ handleBadRequest("The new partition name is missing in the request body! Please add it as plain text.")
+ }
+
+ logger.debug("Changing partition name to $partition")
+ agentBase.dumpReport()
+ agentBase.controller.sessionId = partition
+ agentBase.options.teamscaleServer.partition = partition
+ return Response.noContent().build()
+ }
+
+ /** Handles setting the upload message. */
+ @PUT
+ @Path("/message")
+ fun setMessage(messageString: String): Response {
+ val message = StringUtils.removeDoubleQuotes(messageString)
+ if (message.isEmpty()) {
+ handleBadRequest("The new message is missing in the request body! Please add it as plain text.")
+ }
+
+ agentBase.dumpReport()
+ logger.debug("Changing message to $message")
+ agentBase.options.teamscaleServer.message = message
+
+ return Response.noContent().build()
+ }
+
+ /** Handles setting the revision. */
+ @PUT
+ @Path("/revision")
+ fun setRevision(revisionString: String): Response {
+ val revision = StringUtils.removeDoubleQuotes(revisionString)
+ if (revision.isEmpty()) {
+ handleBadRequest("The new revision name is missing in the request body! Please add it as plain text.")
+ }
+
+ agentBase.dumpReport()
+ logger.debug("Changing revision name to $revision")
+ agentBase.options.teamscaleServer.revision = revision
+
+ return Response.noContent().build()
+ }
+
+ /** Handles setting the upload commit. */
+ @PUT
+ @Path("/commit")
+ fun setCommit(commitString: String): Response {
+ val commit = StringUtils.removeDoubleQuotes(commitString)
+ if (commit.isEmpty()) {
+ handleBadRequest("The new upload commit is missing in the request body! Please add it as plain text.")
+ }
+
+ agentBase.dumpReport()
+ agentBase.options.teamscaleServer.commit = CommitDescriptor.parse(commit)
+
+ return Response.noContent().build()
+ }
+
+ private val revisionInfo: RevisionInfo
+ /** Returns revision information for the Teamscale upload. */
+ get() {
+ val server = agentBase.options.teamscaleServer
+ return RevisionInfo(server.commit, server.revision)
+ }
+
+ /**
+ * Handles bad requests to the endpoints.
+ */
+ @Contract(value = "_ -> fail")
+ @Throws(BadRequestException::class)
+ protected fun handleBadRequest(message: String?) {
+ logger.error(message)
+ throw BadRequestException(message)
+ }
+}
\ No newline at end of file
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/commandline/ICommand.java b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commandline/ICommand.kt
similarity index 77%
rename from agent/src/main/java/com/teamscale/jacoco/agent/commandline/ICommand.java
rename to agent/src/main/kotlin/com/teamscale/jacoco/agent/commandline/ICommand.kt
index 4ce2fa697..5bbc9b7cd 100644
--- a/agent/src/main/java/com/teamscale/jacoco/agent/commandline/ICommand.java
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commandline/ICommand.kt
@@ -3,27 +3,26 @@
| Copyright (c) 2009-2017 CQSE GmbH |
| |
+-------------------------------------------------------------------------*/
-package com.teamscale.jacoco.agent.commandline;
+package com.teamscale.jacoco.agent.commandline
-import com.teamscale.jacoco.agent.options.AgentOptionParseException;
-
-import java.io.IOException;
+import com.teamscale.jacoco.agent.options.AgentOptionParseException
+import java.io.IOException
/**
* Interface for commands: argument parsing and execution.
*/
-public interface ICommand {
-
+interface ICommand {
/**
* Makes sure the arguments are valid. Must return all detected problems in the
* form of a user-visible message.
*/
- Validator validate() throws AgentOptionParseException, IOException;
+ @Throws(AgentOptionParseException::class, IOException::class)
+ fun validate(): Validator
/**
* Runs the implementation of the command. May throw an exception to indicate
* abnormal termination of the program.
*/
- void run() throws Exception;
-
+ @Throws(Exception::class)
+ fun run()
}
\ No newline at end of file
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/commandline/Validator.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commandline/Validator.kt
new file mode 100644
index 000000000..2cf73bb15
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commandline/Validator.kt
@@ -0,0 +1,56 @@
+package com.teamscale.jacoco.agent.commandline
+
+import com.teamscale.client.StringUtils
+import com.teamscale.jacoco.agent.util.Assertions
+
+/**
+ * Helper class to allow for multiple validations to occur.
+ */
+class Validator {
+ /** The found validation problems in the form of error messages for the user. */
+ private val messages = mutableListOf()
+
+ /** Runs the given validation routine. */
+ fun ensure(validation: ExceptionBasedValidation) {
+ try {
+ validation.validate()
+ } catch (e: Exception) {
+ e.message?.let { messages.add(it) }
+ } catch (e: AssertionError) {
+ e.message?.let { messages.add(it) }
+ }
+ }
+
+ /**
+ * Interface for a validation routine that throws an exception when it fails.
+ */
+ fun interface ExceptionBasedValidation {
+ /**
+ * Throws an [Exception] or [AssertionError] if the validation fails.
+ */
+ @Throws(Exception::class, AssertionError::class)
+ fun validate()
+ }
+
+ /**
+ * Checks that the given condition is `true` or adds the given error message.
+ */
+ fun isTrue(condition: Boolean, message: String?) {
+ ensure { Assertions.isTrue(condition, message) }
+ }
+
+ /**
+ * Checks that the given condition is `false` or adds the given error message.
+ */
+ fun isFalse(condition: Boolean, message: String?) {
+ ensure { Assertions.isFalse(condition, message) }
+ }
+
+ val isValid: Boolean
+ /** Returns `true` if the validation succeeded. */
+ get() = messages.isEmpty()
+
+ val errorMessage: String
+ /** Returns an error message with all validation problems that were found. */
+ get() = "- ${messages.joinToString("${StringUtils.LINE_FEED}- ")}"
+}
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/CommitInfo.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/CommitInfo.kt
new file mode 100644
index 000000000..d66014435
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/CommitInfo.kt
@@ -0,0 +1,28 @@
+package com.teamscale.jacoco.agent.commit_resolution.git_properties
+
+import com.teamscale.client.CommitDescriptor
+import com.teamscale.client.StringUtils.isEmpty
+import java.util.*
+
+/** Hold information regarding a commit. */
+data class CommitInfo(
+ /** The revision information (git hash). */
+ @JvmField var revision: String?,
+ /** The commit descriptor. */
+ @JvmField var commit: CommitDescriptor?
+) {
+ /**
+ * If the commit property is set via the `teamscale.commit.branch` and `teamscale.commit.time`
+ * properties in a git.properties file, this should be preferred to the revision. For details see [TS-38561](https://cqse.atlassian.net/browse/TS-38561).
+ */
+ @JvmField
+ var preferCommitDescriptorOverRevision: Boolean = false
+
+ override fun toString() = "$commit/$revision"
+
+ /**
+ * Returns true if one of or both, revision and commit, are set
+ */
+ val isEmpty: Boolean
+ get() = revision.isNullOrEmpty() && commit == null
+}
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitMultiProjectPropertiesLocator.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitMultiProjectPropertiesLocator.kt
new file mode 100644
index 000000000..935272d8e
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitMultiProjectPropertiesLocator.kt
@@ -0,0 +1,95 @@
+package com.teamscale.jacoco.agent.commit_resolution.git_properties
+
+import com.teamscale.jacoco.agent.logging.LoggingUtils.getLogger
+import com.teamscale.jacoco.agent.upload.teamscale.DelayedTeamscaleMultiProjectUploader
+import com.teamscale.jacoco.agent.util.DaemonThreadFactory
+import org.jetbrains.annotations.VisibleForTesting
+import java.io.File
+import java.io.IOException
+import java.time.format.DateTimeFormatter
+import java.util.concurrent.Executor
+import java.util.concurrent.Executors
+
+/**
+ * Searches a Jar/War/Ear/... file for a git.properties file in order to enable upload for the commit described therein,
+ * e.g. to Teamscale, via a [DelayedTeamscaleMultiProjectUploader]. Specifically, this searches for the
+ * 'teamscale.project' property specified in each of the discovered 'git.properties' files.
+ */
+class GitMultiProjectPropertiesLocator(
+ private val uploader: DelayedTeamscaleMultiProjectUploader,
+ private val executor: Executor,
+ private val recursiveSearch: Boolean,
+ private val gitPropertiesCommitTimeFormat: DateTimeFormatter?
+) : IGitPropertiesLocator {
+ private val logger = getLogger(this)
+
+ constructor(
+ uploader: DelayedTeamscaleMultiProjectUploader,
+ recursiveSearch: Boolean,
+ gitPropertiesCommitTimeFormat: DateTimeFormatter?
+ ) : this(
+ uploader, Executors.newSingleThreadExecutor(
+ DaemonThreadFactory(
+ GitMultiProjectPropertiesLocator::class.java,
+ "git.properties Jar scanner thread"
+ )
+ ), recursiveSearch, gitPropertiesCommitTimeFormat
+ )
+
+ /**
+ * Asynchronously searches the given jar file for git.properties files and adds a corresponding uploader to the
+ * multi-project uploader.
+ */
+ override fun searchFileForGitPropertiesAsync(file: File, isJarFile: Boolean) {
+ executor.execute { searchFile(file, isJarFile) }
+ }
+
+ /**
+ * Synchronously searches the given jar file for git.properties files and adds a corresponding uploader to the
+ * multi-project uploader.
+ */
+ @VisibleForTesting
+ fun searchFile(file: File, isJarFile: Boolean) {
+ logger.debug("Searching file {} for multiple git.properties", file.toString())
+ try {
+ val projectAndCommits = GitPropertiesLocatorUtils.getProjectRevisionsFromGitProperties(
+ file, isJarFile, recursiveSearch, gitPropertiesCommitTimeFormat
+ )
+ if (projectAndCommits.isEmpty()) {
+ logger.debug("No git.properties file found in {}", file)
+ return
+ }
+
+ projectAndCommits.forEach { projectAndCommit ->
+ // this code only runs when 'teamscale-project' is not given via the agent properties,
+ // i.e., a multi-project upload is being attempted.
+ // Therefore, we expect to find both the project (teamscale.project) and the revision
+ // (git.commit.id) in the git.properties file.
+ if (projectAndCommit.project == null || projectAndCommit.commitInfo == null) {
+ logger.debug(
+ "Found inconsistent git.properties file: the git.properties file in {} either does not specify the" +
+ " Teamscale project ({}) property, or does not specify the commit " +
+ "({}, {} + {}, or {} + {})." +
+ " Will skip this git.properties file and try to continue with the other ones that were found during discovery.",
+ file, GitPropertiesLocatorUtils.GIT_PROPERTIES_TEAMSCALE_PROJECT,
+ GitPropertiesLocatorUtils.GIT_PROPERTIES_GIT_COMMIT_ID,
+ GitPropertiesLocatorUtils.GIT_PROPERTIES_GIT_BRANCH,
+ GitPropertiesLocatorUtils.GIT_PROPERTIES_GIT_COMMIT_TIME,
+ GitPropertiesLocatorUtils.GIT_PROPERTIES_TEAMSCALE_COMMIT_BRANCH,
+ GitPropertiesLocatorUtils.GIT_PROPERTIES_TEAMSCALE_COMMIT_TIME
+ )
+ return@forEach
+ }
+ logger.debug(
+ "Found git.properties file in {} and found Teamscale project {} and revision {}", file,
+ projectAndCommit.project, projectAndCommit.commitInfo
+ )
+ uploader.addTeamscaleProjectAndCommit(file, projectAndCommit)
+ }
+ } catch (e: IOException) {
+ logger.error("Error during asynchronous search for git.properties in {}", file, e)
+ } catch (e: InvalidGitPropertiesException) {
+ logger.error("Error during asynchronous search for git.properties in {}", file, e)
+ }
+ }
+}
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitPropertiesLocatingTransformer.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitPropertiesLocatingTransformer.kt
new file mode 100644
index 000000000..a93df303c
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitPropertiesLocatingTransformer.kt
@@ -0,0 +1,78 @@
+package com.teamscale.jacoco.agent.commit_resolution.git_properties
+
+import com.teamscale.jacoco.agent.logging.LoggingUtils.getLogger
+import com.teamscale.report.util.ClasspathWildcardIncludeFilter
+import java.io.File
+import java.lang.instrument.ClassFileTransformer
+import java.security.ProtectionDomain
+import java.util.concurrent.ConcurrentSkipListSet
+
+/**
+ * [ClassFileTransformer] that doesn't change the loaded classes but searches their corresponding Jar/War/Ear/...
+ * files for a git.properties file.
+ */
+class GitPropertiesLocatingTransformer(
+ private val locator: IGitPropertiesLocator,
+ private val locationIncludeFilter: ClasspathWildcardIncludeFilter
+) : ClassFileTransformer {
+ private val logger = getLogger(this)
+ private val seenJars = ConcurrentSkipListSet()
+
+ override fun transform(
+ classLoader: ClassLoader?,
+ className: String,
+ aClass: Class<*>?,
+ protectionDomain: ProtectionDomain?,
+ classFileContent: ByteArray?
+ ): ByteArray? {
+ if (protectionDomain == null) {
+ // happens for e.g. java.lang. We can ignore these classes
+ return null
+ }
+
+ if (className.isEmpty() || !locationIncludeFilter.isIncluded(className)) {
+ // only search in jar files of included classes
+ return null
+ }
+
+ try {
+ val codeSource = protectionDomain.codeSource
+ if (codeSource == null || codeSource.location == null) {
+ // unknown when this can happen, we suspect when code is generated at runtime
+ // but there's nothing else we can do here in either case.
+ // codeSource.getLocation() is null e.g. when executing Pixelitor with Java14 for class sun/reflect/misc/Trampoline
+ logger.debug(
+ "Could not locate code source for class {}. Skipping git.properties search for this class",
+ className
+ )
+ return null
+ }
+
+ val jarOrClassFolderUrl = codeSource.location
+ val searchRoot = GitPropertiesLocatorUtils.extractGitPropertiesSearchRoot(jarOrClassFolderUrl)
+ if (searchRoot == null) {
+ logger.warn(
+ "Not searching location for git.properties with unknown protocol or extension {}." +
+ " If this location contains your git.properties, please report this warning as a" +
+ " bug to CQSE. In that case, auto-discovery of git.properties will not work.",
+ jarOrClassFolderUrl
+ )
+ return null
+ }
+
+ if (hasLocationAlreadyBeenSearched(searchRoot.first)) {
+ return null
+ }
+
+ logger.debug("Scheduling asynchronous search for git.properties in {}", searchRoot)
+ locator.searchFileForGitPropertiesAsync(searchRoot.first, searchRoot.second)
+ } catch (e: Throwable) {
+ // we catch Throwable to be sure that we log all errors as anything thrown from this method is
+ // silently discarded by the JVM
+ logger.error("Failed to process class {} in search of git.properties", className, e)
+ }
+ return null
+ }
+
+ private fun hasLocationAlreadyBeenSearched(location: File) = !seenJars.add(location.toString())
+}
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitPropertiesLocatorUtils.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitPropertiesLocatorUtils.kt
new file mode 100644
index 000000000..5af6f29ff
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitPropertiesLocatorUtils.kt
@@ -0,0 +1,508 @@
+package com.teamscale.jacoco.agent.commit_resolution.git_properties
+
+import com.teamscale.client.CommitDescriptor
+import com.teamscale.client.FileSystemUtils.listFilesRecursively
+import com.teamscale.client.StringUtils.endsWithOneOf
+import com.teamscale.client.StringUtils.isEmpty
+import com.teamscale.jacoco.agent.options.ProjectAndCommit
+import com.teamscale.report.util.BashFileSkippingInputStream
+import java.io.File
+import java.io.IOException
+import java.lang.reflect.InvocationTargetException
+import java.net.URI
+import java.net.URISyntaxException
+import java.net.URL
+import java.nio.file.Files
+import java.nio.file.Paths
+import java.time.ZonedDateTime
+import java.time.format.DateTimeFormatter
+import java.time.format.DateTimeFormatterBuilder
+import java.time.format.DateTimeParseException
+import java.util.*
+import java.util.jar.JarEntry
+import java.util.jar.JarInputStream
+import java.util.regex.Pattern
+
+/** Utility methods to extract certain properties from git.properties files in archives and folders. */
+object GitPropertiesLocatorUtils {
+ /** Name of the git.properties file. */
+ const val GIT_PROPERTIES_FILE_NAME: String = "git.properties"
+
+ /** The git.properties key that holds the commit time. */
+ const val GIT_PROPERTIES_GIT_COMMIT_TIME: String = "git.commit.time"
+
+ /** The git.properties key that holds the commit branch. */
+ const val GIT_PROPERTIES_GIT_BRANCH: String = "git.branch"
+
+ /** The git.properties key that holds the commit hash. */
+ const val GIT_PROPERTIES_GIT_COMMIT_ID: String = "git.commit.id"
+
+ /**
+ * Alternative git.properties key that might also hold the commit hash, depending on the Maven git-commit-id plugin
+ * configuration.
+ */
+ const val GIT_PROPERTIES_GIT_COMMIT_ID_FULL: String = "git.commit.id.full"
+
+ /**
+ * You can provide a teamscale timestamp in git.properties files to overwrite the revision. See [TS-38561](https://cqse.atlassian.net/browse/TS-38561).
+ */
+ const val GIT_PROPERTIES_TEAMSCALE_COMMIT_BRANCH: String = "teamscale.commit.branch"
+
+ /**
+ * You can provide a teamscale timestamp in git.properties files to overwrite the revision. See [TS-38561](https://cqse.atlassian.net/browse/TS-38561).
+ */
+ const val GIT_PROPERTIES_TEAMSCALE_COMMIT_TIME: String = "teamscale.commit.time"
+
+ /** The git.properties key that holds the Teamscale project name. */
+ const val GIT_PROPERTIES_TEAMSCALE_PROJECT: String = "teamscale.project"
+
+ /** Matches the path to the jar file in a jar:file: URL in regex group 1. */
+ private val JAR_URL_REGEX: Pattern = Pattern.compile(
+ "jar:(?:file|nested):(.*?)!.*",
+ Pattern.CASE_INSENSITIVE
+ )
+
+ private val NESTED_JAR_REGEX: Pattern = Pattern.compile(
+ "[jwea]ar:file:(.*?)\\*(.*)",
+ Pattern.CASE_INSENSITIVE
+ )
+
+ /**
+ * Defined in [GitCommitIdMojo](https://github.com/git-commit-id/git-commit-id-maven-plugin/blob/ac05b16dfdcc2aebfa45ad3af4acf1254accffa3/src/main/java/pl/project13/maven/git/GitCommitIdMojo.java#L522)
+ */
+ private const val GIT_PROPERTIES_DEFAULT_MAVEN_DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ssXXX"
+
+ /**
+ * Defined in [GitPropertiesPlugin](https://github.com/n0mer/gradle-git-properties/blob/bb1c3353bb570495644b6c6c75e211296a8354fc/src/main/groovy/com/gorylenko/GitPropertiesPlugin.groovy#L68)
+ */
+ private const val GIT_PROPERTIES_DEFAULT_GRADLE_DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ssZ"
+
+ /**
+ * Reads the git SHA1 and branch and timestamp from the given jar file's git.properties and builds a commit
+ * descriptor out of it. If no git.properties file can be found, returns null.
+ *
+ * @throws IOException If reading the jar file fails.
+ * @throws InvalidGitPropertiesException If a git.properties file is found but it is malformed.
+ */
+ @JvmStatic
+ @Throws(IOException::class, InvalidGitPropertiesException::class)
+ fun getCommitInfoFromGitProperties(
+ file: File,
+ isJarFile: Boolean,
+ recursiveSearch: Boolean,
+ gitPropertiesCommitTimeFormat: DateTimeFormatter?
+ ) = findGitPropertiesInFile(file, isJarFile, recursiveSearch).map { entryWithProperties ->
+ getCommitInfoFromGitProperties(
+ entryWithProperties.second, entryWithProperties.first, file,
+ gitPropertiesCommitTimeFormat
+ )
+ }
+
+ /**
+ * Tries to extract a file system path to a search root for the git.properties search. A search root is either a
+ * file system folder or a Jar file. If no such path can be extracted, returns null.
+ *
+ * @throws URISyntaxException under certain circumstances if parsing the URL fails. This should be treated the same
+ * as a null search result but the exception is preserved so it can be logged.
+ */
+ @JvmStatic
+ @Throws(
+ URISyntaxException::class,
+ IOException::class,
+ NoSuchMethodException::class,
+ IllegalAccessException::class,
+ InvocationTargetException::class
+ )
+ fun extractGitPropertiesSearchRoot(
+ jarOrClassFolderUrl: URL
+ ): Pair? {
+ val protocol = jarOrClassFolderUrl.protocol.lowercase(Locale.getDefault())
+ when (protocol) {
+ "file" -> {
+ val jarOrClassFolderFile = File(jarOrClassFolderUrl.toURI())
+ if (jarOrClassFolderFile.isDirectory() || isJarLikeFile(jarOrClassFolderUrl.path)) {
+ return jarOrClassFolderFile to !jarOrClassFolderFile.isDirectory()
+ }
+ }
+
+ "jar" -> {
+ // Used e.g. by Spring Boot. Example: jar:file:/home/k/demo.jar!/BOOT-INF/classes!/
+ val jarMatcher = JAR_URL_REGEX.matcher(jarOrClassFolderUrl.toString())
+ if (jarMatcher.matches()) {
+ return File(jarMatcher.group(1)) to true
+ }
+ // Used by some web applications and potentially fat jars.
+ // Example: war:file:/Users/example/apache-tomcat/webapps/demo.war*/WEB-INF/lib/demoLib-1.0-SNAPSHOT.jar
+ val nestedMatcher = NESTED_JAR_REGEX.matcher(jarOrClassFolderUrl.toString())
+ if (nestedMatcher.matches()) {
+ return File(nestedMatcher.group(1)) to true
+ }
+ }
+
+ "war", "ear" -> {
+ val nestedMatcher = NESTED_JAR_REGEX.matcher(jarOrClassFolderUrl.toString())
+ if (nestedMatcher.matches()) {
+ return File(nestedMatcher.group(1)) to true
+ }
+ }
+
+ "vfs" -> return getVfsContentFolder(jarOrClassFolderUrl)
+ else -> return null
+ }
+ return null
+ }
+
+ /**
+ * VFS (Virtual File System) protocol is used by JBoss EAP and Wildfly. Example of an URL:
+ * vfs:/content/helloworld.war/WEB-INF/classes
+ */
+ @Throws(
+ IOException::class,
+ NoSuchMethodException::class,
+ IllegalAccessException::class,
+ InvocationTargetException::class
+ )
+ private fun getVfsContentFolder(
+ jarOrClassFolderUrl: URL
+ ): Pair {
+ // we obtain the URL of a specific class file as input, e.g.,
+ // vfs:/content/helloworld.war/WEB-INF/classes
+ // Next, we try to extract the artefact URL from it, e.g., vfs:/content/helloworld.war
+ val artefactUrl = extractArtefactUrl(jarOrClassFolderUrl)
+ val virtualFile = URI.create(artefactUrl).toURL().openConnection().getContent()
+ // obtain the physical location of the class file. It is created on demand in /standalone/tmp/vfs
+ val getPhysicalFileMethod = virtualFile.javaClass.getMethod("getPhysicalFile")
+ val file = getPhysicalFileMethod.invoke(virtualFile) as File
+ return file to !file.isDirectory()
+ }
+
+ /**
+ * Extracts the artefact URL (e.g., vfs:/content/helloworld.war/) from the full URL of the class file (e.g.,
+ * vfs:/content/helloworld.war/WEB-INF/classes).
+ */
+ private fun extractArtefactUrl(jarOrClassFolderUrl: URL): String {
+ val url = jarOrClassFolderUrl.path.lowercase(Locale.getDefault())
+ val pathSegments = url.split("/".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
+ val artefactUrlBuilder = StringBuilder("vfs:")
+ var segmentIdx = 0
+ while (segmentIdx < pathSegments.size) {
+ val segment = pathSegments[segmentIdx]
+ artefactUrlBuilder.append(segment)
+ artefactUrlBuilder.append("/")
+ if (isJarLikeFile(segment)) {
+ break
+ }
+ segmentIdx += 1
+ }
+ if (segmentIdx == pathSegments.size) {
+ return url
+ }
+ return artefactUrlBuilder.toString()
+ }
+
+ private fun isJarLikeFile(segment: String) = endsWithOneOf(
+ segment.lowercase(Locale.getDefault()), ".jar", ".war", ".ear", ".aar"
+ )
+
+ /**
+ * Reads the 'teamscale.project' property values and the git SHA1s or branch + timestamp from all git.properties
+ * files contained in the provided folder or archive file.
+ *
+ * @throws IOException If reading the jar file fails.
+ * @throws InvalidGitPropertiesException If a git.properties file is found but it is malformed.
+ */
+ @JvmStatic
+ @Throws(IOException::class, InvalidGitPropertiesException::class)
+ fun getProjectRevisionsFromGitProperties(
+ file: File,
+ isJarFile: Boolean,
+ recursiveSearch: Boolean,
+ gitPropertiesCommitTimeFormat: DateTimeFormatter?
+ ) = findGitPropertiesInFile(
+ file, isJarFile,
+ recursiveSearch
+ ).map { entryWithProperties ->
+ val commitInfo = getCommitInfoFromGitProperties(
+ entryWithProperties.second,
+ entryWithProperties.first, file, gitPropertiesCommitTimeFormat
+ )
+ val project = entryWithProperties.second.getProperty(GIT_PROPERTIES_TEAMSCALE_PROJECT)
+ if (commitInfo.isEmpty && isEmpty(project)) {
+ throw InvalidGitPropertiesException(
+ "No entry or empty value for both '$GIT_PROPERTIES_GIT_COMMIT_ID'/'$GIT_PROPERTIES_GIT_COMMIT_ID_FULL' and '$GIT_PROPERTIES_TEAMSCALE_PROJECT' in $file.\nContents of $GIT_PROPERTIES_FILE_NAME: ${entryWithProperties.second}"
+ )
+ }
+ ProjectAndCommit(project, commitInfo)
+ }
+
+ /**
+ * Returns pairs of paths to git.properties files and their parsed properties found in the provided folder or
+ * archive file. Nested jar files will also be searched recursively if specified.
+ */
+ @JvmStatic
+ @Throws(IOException::class)
+ fun findGitPropertiesInFile(
+ file: File, isJarFile: Boolean, recursiveSearch: Boolean
+ ): List> {
+ if (isJarFile) {
+ return findGitPropertiesInArchiveFile(file, recursiveSearch)
+ }
+ return findGitPropertiesInDirectoryFile(file, recursiveSearch)
+ }
+
+ /**
+ * Searches for git properties in jar/war/ear/aar files
+ */
+ @Throws(IOException::class)
+ private fun findGitPropertiesInArchiveFile(
+ file: File,
+ recursiveSearch: Boolean
+ ): List> {
+ try {
+ JarInputStream(
+ BashFileSkippingInputStream(Files.newInputStream(file.toPath()))
+ ).use { jarStream ->
+ return findGitPropertiesInArchive(jarStream, file.getName(), recursiveSearch)
+ }
+ } catch (e: IOException) {
+ throw IOException(
+ "Reading jar ${file.absolutePath} for obtaining commit descriptor from git.properties failed", e
+ )
+ }
+ }
+
+ /**
+ * Searches for git.properties file in the given folder
+ *
+ * @param recursiveSearch If enabled, git.properties files will also be searched in jar files
+ */
+ @Throws(IOException::class)
+ private fun findGitPropertiesInDirectoryFile(
+ directoryFile: File, recursiveSearch: Boolean
+ ): List> {
+ val result = findGitPropertiesInFolder(directoryFile).toMutableList()
+
+ if (recursiveSearch) {
+ result.addAll(findGitPropertiesInNestedJarFiles(directoryFile))
+ }
+
+ return result.toList()
+ }
+
+ /**
+ * Finds all jar files in the given folder and searches them recursively for git.properties
+ */
+ @Throws(IOException::class)
+ private fun findGitPropertiesInNestedJarFiles(directoryFile: File) =
+ listFilesRecursively(directoryFile) {
+ isJarLikeFile(it.getName())
+ }.flatMap { jarFile ->
+ val inputStream = JarInputStream(Files.newInputStream(jarFile.toPath()))
+ val relativeFilePath = "${directoryFile.getName()}${File.separator}" + directoryFile.toPath()
+ .relativize(jarFile.toPath())
+ findGitPropertiesInArchive(inputStream, relativeFilePath, true)
+ }
+
+ /**
+ * Searches for git.properties files in the given folder
+ */
+ @Throws(IOException::class)
+ private fun findGitPropertiesInFolder(directoryFile: File) =
+ listFilesRecursively(directoryFile) {
+ it.getName().equals(GIT_PROPERTIES_FILE_NAME, ignoreCase = true)
+ }.map { gitPropertiesFile ->
+ try {
+ Files.newInputStream(gitPropertiesFile.toPath()).use { inputStream ->
+ val gitProperties = Properties()
+ gitProperties.load(inputStream)
+ val relativeFilePath = "${directoryFile.getName()}${File.separator}" + directoryFile.toPath()
+ .relativize(gitPropertiesFile.toPath())
+ relativeFilePath to gitProperties
+ }
+ } catch (e: IOException) {
+ throw IOException(
+ "Reading directory ${gitPropertiesFile.absolutePath} for obtaining commit descriptor from git.properties failed", e
+ )
+ }
+ }
+
+ /**
+ * Returns pairs of paths to git.properties files and their parsed properties found in the provided JarInputStream.
+ * Nested jar files will also be searched recursively if specified.
+ */
+ @JvmStatic
+ @JvmOverloads
+ @Throws(IOException::class)
+ fun findGitPropertiesInArchive(
+ inputStream: JarInputStream,
+ archiveName: String?,
+ recursiveSearch: Boolean,
+ isRootArchive: Boolean = true
+ ): MutableList> {
+ val result = mutableListOf>()
+ var isEmpty = true
+
+ var entry = inputStream.nextJarEntry
+ while (entry != null) {
+ isEmpty = false
+ val fullEntryName = if (archiveName.isNullOrEmpty()) entry.name else "$archiveName${File.separator}${entry.name}"
+ val fileName = entry.name.substringAfterLast('/')
+
+ if (fileName.equals(GIT_PROPERTIES_FILE_NAME, ignoreCase = true)) {
+ val gitProperties = Properties().apply { load(inputStream) }
+ result.add(fullEntryName to gitProperties)
+ } else if (recursiveSearch && isJarLikeFile(entry.name)) {
+ val nestedJarStream = JarInputStream(inputStream)
+ result.addAll(
+ findGitPropertiesInArchive(nestedJarStream, fullEntryName,
+ recursiveSearch = true,
+ isRootArchive = false
+ )
+ )
+ }
+ entry = inputStream.nextJarEntry
+ }
+
+ if (isEmpty && isRootArchive) {
+ throw IOException("No entries in Jar file $archiveName. Is this a valid jar file?. If so, please report to CQSE.")
+ }
+
+ return result
+ }
+
+ /**
+ * Returns the CommitInfo (revision and branch + timestmap) from a git properties file. The revision can be either
+ * in [GIT_PROPERTIES_GIT_COMMIT_ID] or [GIT_PROPERTIES_GIT_COMMIT_ID_FULL]. The branch and timestamp
+ * in [GIT_PROPERTIES_GIT_BRANCH] + [GIT_PROPERTIES_GIT_COMMIT_TIME] or in
+ * [GIT_PROPERTIES_TEAMSCALE_COMMIT_BRANCH] + [GIT_PROPERTIES_TEAMSCALE_COMMIT_TIME]. By default,
+ * times will be parsed with [GIT_PROPERTIES_DEFAULT_GRADLE_DATE_FORMAT] and
+ * [GIT_PROPERTIES_DEFAULT_MAVEN_DATE_FORMAT]. An additional format can be given with
+ * `dateTimeFormatter`
+ */
+ @JvmStatic
+ @Throws(InvalidGitPropertiesException::class)
+ fun getCommitInfoFromGitProperties(
+ gitProperties: Properties, entryName: String?, jarFile: File?,
+ additionalDateTimeFormatter: DateTimeFormatter?
+ ): CommitInfo {
+ val dateTimeFormatter = createDateTimeFormatter(additionalDateTimeFormatter)
+
+ val revision = getRevisionFromGitProperties(gitProperties)
+
+ // Get branch and timestamp from git.commit.branch and git.commit.id
+ var commitDescriptor = getCommitDescriptorFromDefaultGitPropertyValues(
+ gitProperties, entryName, jarFile, dateTimeFormatter
+ )
+ // When read from these properties, we should prefer to upload to the revision
+ var preferCommitDescriptorOverRevision = false
+
+ // Get branch and timestamp from teamscale.commit.branch and teamscale.commit.time (TS-38561)
+ val teamscaleTimestampBasedCommitDescriptor = getCommitDescriptorFromTeamscaleTimestampProperty(
+ gitProperties, entryName, jarFile, dateTimeFormatter
+ )
+ if (teamscaleTimestampBasedCommitDescriptor != null) {
+ // In this case, as we specifically set this property, we should prefer branch and timestamp to the revision
+ preferCommitDescriptorOverRevision = true
+ commitDescriptor = teamscaleTimestampBasedCommitDescriptor
+ }
+
+ if (isEmpty(revision) && commitDescriptor == null) {
+ throw InvalidGitPropertiesException(
+ "No entry or invalid value for '" + GIT_PROPERTIES_GIT_COMMIT_ID + "', '" + GIT_PROPERTIES_GIT_COMMIT_ID_FULL +
+ "', '" + GIT_PROPERTIES_TEAMSCALE_COMMIT_BRANCH + "' and " + GIT_PROPERTIES_TEAMSCALE_COMMIT_TIME + "'\n" +
+ "Location: Entry '" + entryName + "' in jar file '" + jarFile + "'." +
+ "\nContents of " + GIT_PROPERTIES_FILE_NAME + ":\n" + gitProperties
+ )
+ }
+
+ val commitInfo = CommitInfo(revision, commitDescriptor)
+ commitInfo.preferCommitDescriptorOverRevision = preferCommitDescriptorOverRevision
+ return commitInfo
+ }
+
+ private fun createDateTimeFormatter(
+ additionalDateTimeFormatter: DateTimeFormatter?
+ ): DateTimeFormatter {
+ val defaultDateTimeFormatter = DateTimeFormatter.ofPattern(
+ String.format(
+ "[%s][%s]", GIT_PROPERTIES_DEFAULT_MAVEN_DATE_FORMAT,
+ GIT_PROPERTIES_DEFAULT_GRADLE_DATE_FORMAT
+ )
+ )
+ val builder = DateTimeFormatterBuilder().append(defaultDateTimeFormatter)
+ if (additionalDateTimeFormatter != null) {
+ builder.append(additionalDateTimeFormatter)
+ }
+ return builder.toFormatter()
+ }
+
+ private fun getRevisionFromGitProperties(gitProperties: Properties): String? {
+ var revision = gitProperties.getProperty(GIT_PROPERTIES_GIT_COMMIT_ID)
+ if (revision.isNullOrEmpty()) {
+ revision = gitProperties.getProperty(GIT_PROPERTIES_GIT_COMMIT_ID_FULL)
+ }
+ return revision
+ }
+
+ @Throws(InvalidGitPropertiesException::class)
+ private fun getCommitDescriptorFromTeamscaleTimestampProperty(
+ gitProperties: Properties,
+ entryName: String?,
+ jarFile: File?,
+ dateTimeFormatter: DateTimeFormatter
+ ): CommitDescriptor? {
+ val teamscaleCommitBranch = gitProperties.getProperty(GIT_PROPERTIES_TEAMSCALE_COMMIT_BRANCH)
+ val teamscaleCommitTime = gitProperties.getProperty(GIT_PROPERTIES_TEAMSCALE_COMMIT_TIME)
+
+ if (teamscaleCommitBranch.isNullOrEmpty() || teamscaleCommitTime.isNullOrEmpty()) {
+ return null
+ }
+
+ val teamscaleTimestampRegex = "\\d*(?:p\\d*)?"
+ val teamscaleTimestampMatcher = Pattern.compile(teamscaleTimestampRegex).matcher(teamscaleCommitTime)
+ if (teamscaleTimestampMatcher.matches()) {
+ return CommitDescriptor(teamscaleCommitBranch, teamscaleCommitTime)
+ }
+
+ val epochTimestamp: Long
+ try {
+ epochTimestamp = ZonedDateTime.parse(teamscaleCommitTime, dateTimeFormatter).toInstant().toEpochMilli()
+ } catch (e: DateTimeParseException) {
+ throw InvalidGitPropertiesException(
+ ("Cannot parse commit time '" + teamscaleCommitTime + "' in the '" + GIT_PROPERTIES_TEAMSCALE_COMMIT_TIME +
+ "' property. It needs to be in the date formats '" + GIT_PROPERTIES_DEFAULT_MAVEN_DATE_FORMAT +
+ "' or '" + GIT_PROPERTIES_DEFAULT_GRADLE_DATE_FORMAT + "' or match the Teamscale timestamp format '"
+ + teamscaleTimestampRegex + "'." +
+ "\nLocation: Entry '" + entryName + "' in jar file '" + jarFile + "'." +
+ "\nContents of " + GIT_PROPERTIES_FILE_NAME + ":\n" + gitProperties), e
+ )
+ }
+
+ return CommitDescriptor(teamscaleCommitBranch, epochTimestamp)
+ }
+
+ @Throws(InvalidGitPropertiesException::class)
+ private fun getCommitDescriptorFromDefaultGitPropertyValues(
+ gitProperties: Properties,
+ entryName: String?,
+ jarFile: File?,
+ dateTimeFormatter: DateTimeFormatter
+ ): CommitDescriptor? {
+ val gitBranch = gitProperties.getProperty(GIT_PROPERTIES_GIT_BRANCH)
+ val gitTime = gitProperties.getProperty(GIT_PROPERTIES_GIT_COMMIT_TIME)
+ if (!gitBranch.isNullOrEmpty() && !gitTime.isNullOrEmpty()) {
+ val gitTimestamp: Long
+ try {
+ gitTimestamp = ZonedDateTime.parse(gitTime, dateTimeFormatter).toInstant().toEpochMilli()
+ } catch (e: DateTimeParseException) {
+ throw InvalidGitPropertiesException(
+ "Could not parse the timestamp in property '" + GIT_PROPERTIES_GIT_COMMIT_TIME + "'." +
+ "\nLocation: Entry '" + entryName + "' in jar file '" + jarFile + "'." +
+ "\nContents of " + GIT_PROPERTIES_FILE_NAME + ":\n" + gitProperties, e
+ )
+ }
+ return CommitDescriptor(gitBranch, gitTimestamp)
+ }
+ return null
+ }
+}
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitSingleProjectPropertiesLocator.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitSingleProjectPropertiesLocator.kt
new file mode 100644
index 000000000..708b33db9
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitSingleProjectPropertiesLocator.kt
@@ -0,0 +1,104 @@
+package com.teamscale.jacoco.agent.commit_resolution.git_properties
+
+import com.teamscale.jacoco.agent.logging.LoggingUtils.getLogger
+import com.teamscale.jacoco.agent.upload.delay.DelayedUploader
+import com.teamscale.jacoco.agent.util.DaemonThreadFactory
+import java.io.File
+import java.io.IOException
+import java.time.format.DateTimeFormatter
+import java.util.concurrent.Executor
+import java.util.concurrent.Executors
+
+/**
+ * Searches a Jar/War/Ear/... file for a git.properties file in order to enable upload for the commit described therein,
+ * e.g. to Teamscale, via a [DelayedUploader].
+ */
+class GitSingleProjectPropertiesLocator(
+ private val uploader: DelayedUploader,
+ private val dataExtractor: DataExtractor,
+ private val executor: Executor,
+ private val recursiveSearch: Boolean,
+ private val gitPropertiesCommitTimeFormat: DateTimeFormatter?
+) : IGitPropertiesLocator {
+ private val logger = getLogger(this)
+ private var foundData: T? = null
+ private var jarFileWithGitProperties: File? = null
+
+ constructor(
+ uploader: DelayedUploader,
+ dataExtractor: DataExtractor,
+ recursiveSearch: Boolean,
+ gitPropertiesCommitTimeFormat: DateTimeFormatter?
+ ) : this(
+ uploader, dataExtractor, Executors.newSingleThreadExecutor(
+ DaemonThreadFactory(
+ GitSingleProjectPropertiesLocator::class.java,
+ "git.properties Jar scanner thread"
+ )
+ ), recursiveSearch, gitPropertiesCommitTimeFormat
+ )
+
+ /**
+ * Asynchronously searches the given jar file for a git.properties file.
+ */
+ override fun searchFileForGitPropertiesAsync(file: File, isJarFile: Boolean) {
+ executor.execute { searchFile(file, isJarFile) }
+ }
+
+ private fun searchFile(file: File, isJarFile: Boolean) {
+ logger.debug("Searching jar file {} for a single git.properties", file)
+ try {
+ val data = dataExtractor.extractData(file, isJarFile, recursiveSearch, gitPropertiesCommitTimeFormat)
+ if (data.isEmpty()) {
+ logger.debug("No git.properties files found in {}", file.toString())
+ return
+ }
+ if (data.size > 1) {
+ logger.warn(
+ "Multiple git.properties files found in {}", file.toString() +
+ ". Using the first one: " + data.first()
+ )
+ }
+ val dataEntry = data.first()
+
+ if (foundData != null) {
+ if (foundData != dataEntry) {
+ logger.warn(
+ "Found inconsistent git.properties files: {} contained data {} while {} contained {}." +
+ " Please ensure that all git.properties files of your application are consistent." +
+ " Otherwise, you may" +
+ " be uploading to the wrong project/commit which will result in incorrect coverage data" +
+ " displayed in Teamscale. If you cannot fix the inconsistency, you can manually" +
+ " specify a Jar/War/Ear/... file from which to read the correct git.properties" +
+ " file with the agent's teamscale-git-properties-jar parameter.",
+ jarFileWithGitProperties, foundData, file, data
+ )
+ }
+ return
+ }
+
+ logger.debug(
+ "Found git.properties file in {} and found commit descriptor {}", file.toString(),
+ dataEntry
+ )
+ foundData = dataEntry
+ jarFileWithGitProperties = file
+ uploader.setCommitAndTriggerAsynchronousUpload(dataEntry)
+ } catch (e: IOException) {
+ logger.error("Error during asynchronous search for git.properties in {}", file.toString(), e)
+ } catch (e: InvalidGitPropertiesException) {
+ logger.error("Error during asynchronous search for git.properties in {}", file.toString(), e)
+ }
+ }
+
+ /** Functional interface for data extraction from a jar file. */
+ fun interface DataExtractor {
+ /** Extracts data from the JAR. */
+ @Throws(IOException::class, InvalidGitPropertiesException::class)
+ fun extractData(
+ file: File, isJarFile: Boolean,
+ recursiveSearch: Boolean,
+ gitPropertiesCommitTimeFormat: DateTimeFormatter?
+ ): List
+ }
+}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/IGitPropertiesLocator.java b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/IGitPropertiesLocator.kt
similarity index 61%
rename from agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/IGitPropertiesLocator.java
rename to agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/IGitPropertiesLocator.kt
index 0fc60c7ce..30f724b69 100644
--- a/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/IGitPropertiesLocator.java
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/IGitPropertiesLocator.kt
@@ -1,13 +1,12 @@
-package com.teamscale.jacoco.agent.commit_resolution.git_properties;
+package com.teamscale.jacoco.agent.commit_resolution.git_properties
-import java.io.File;
-
-/** Interface for the locator classes that search files (e.g., a JAR) for git.properties files containing certain properties. */
-public interface IGitPropertiesLocator {
+import java.io.File
+/** Interface for the locator classes that search files (e.g., a JAR) for git.properties files containing certain properties. */
+interface IGitPropertiesLocator {
/**
* Searches the file for the git.properties file containing certain properties. The boolean flag indicates whether the
* searched file is a JAR file or a plain directory.
*/
- void searchFileForGitPropertiesAsync(File file, boolean isJarFile);
+ fun searchFileForGitPropertiesAsync(file: File, isJarFile: Boolean)
}
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/InvalidGitPropertiesException.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/InvalidGitPropertiesException.kt
new file mode 100644
index 000000000..bbfb2e59e
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/InvalidGitPropertiesException.kt
@@ -0,0 +1,9 @@
+package com.teamscale.jacoco.agent.commit_resolution.git_properties
+
+/**
+ * Thrown in case a git.properties file is found but it is malformed.
+ */
+class InvalidGitPropertiesException : Exception {
+ internal constructor(s: String, throwable: Throwable?) : super(s, throwable)
+ constructor(s: String) : super(s)
+}
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/sapnwdi/NwdiMarkerClassLocatingTransformer.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/sapnwdi/NwdiMarkerClassLocatingTransformer.kt
new file mode 100644
index 000000000..9f5d02bb1
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/sapnwdi/NwdiMarkerClassLocatingTransformer.kt
@@ -0,0 +1,80 @@
+package com.teamscale.jacoco.agent.commit_resolution.sapnwdi
+
+import com.teamscale.client.CommitDescriptor
+import com.teamscale.client.StringUtils.isEmpty
+import com.teamscale.jacoco.agent.logging.LoggingUtils.getLogger
+import com.teamscale.jacoco.agent.options.sapnwdi.DelayedSapNwdiMultiUploader
+import com.teamscale.jacoco.agent.options.sapnwdi.SapNwdiApplication
+import com.teamscale.report.util.ClasspathWildcardIncludeFilter
+import java.lang.instrument.ClassFileTransformer
+import java.nio.file.Files
+import java.nio.file.Paths
+import java.nio.file.attribute.BasicFileAttributes
+import java.security.ProtectionDomain
+import java.util.function.Function
+import java.util.stream.Collectors
+
+/**
+ * [ClassFileTransformer] that doesn't change the loaded classes but guesses the rough commit timestamp by
+ * inspecting the last modification date of the applications marker class file.
+ */
+class NwdiMarkerClassLocatingTransformer(
+ private val store: DelayedSapNwdiMultiUploader,
+ private val locationIncludeFilter: ClasspathWildcardIncludeFilter,
+ apps: List
+) : ClassFileTransformer {
+ private val logger = getLogger(this)
+ private val markerClassesToApplications =
+ apps.associateBy { it.markerClass.replace('.', '/') }
+
+ override fun transform(
+ classLoader: ClassLoader?,
+ className: String,
+ aClass: Class<*>?,
+ protectionDomain: ProtectionDomain?,
+ classFileContent: ByteArray?
+ ): ByteArray? {
+ if (protectionDomain == null) {
+ // happens for e.g. java.lang. We can ignore these classes
+ return null
+ }
+
+ if (className.isEmpty() || !locationIncludeFilter.isIncluded(className)) {
+ // only search in jar files of included classes
+ return null
+ }
+
+ // only kick off search if the marker class was found.
+ val application = markerClassesToApplications[className] ?: return null
+
+ try {
+ // unknown when this can happen, we suspect when code is generated at runtime
+ // but there's nothing else we can do here in either case
+ val codeSource = protectionDomain.codeSource ?: return null
+
+ val jarOrClassFolderUrl = codeSource.location
+ logger.debug("Found {} in {}", className, jarOrClassFolderUrl)
+
+ if (jarOrClassFolderUrl.protocol.equals("file", ignoreCase = true)) {
+ val file = Paths.get(jarOrClassFolderUrl.toURI())
+ val attr = Files.readAttributes(file, BasicFileAttributes::class.java)
+ val commitDescriptor = CommitDescriptor(
+ DTR_BRIDGE_DEFAULT_BRANCH, attr.lastModifiedTime().toMillis()
+ )
+ store.setCommitForApplication(commitDescriptor, application)
+ }
+ } catch (e: Throwable) {
+ // we catch Throwable to be sure that we log all errors as anything thrown from this method is
+ // silently discarded by the JVM
+ logger.error(
+ "Failed to process class {} trying to determine its last modification timestamp.", className, e
+ )
+ }
+ return null
+ }
+
+ companion object {
+ /** The Design time repository-git-bridge (DTR-bridge) currently only exports a single branch named master. */
+ private const val DTR_BRIDGE_DEFAULT_BRANCH = "master"
+ }
+}
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/configuration/AgentOptionReceiveException.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/configuration/AgentOptionReceiveException.kt
new file mode 100644
index 000000000..40c0a3104
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/configuration/AgentOptionReceiveException.kt
@@ -0,0 +1,7 @@
+package com.teamscale.jacoco.agent.configuration
+
+/** Thrown when retrieving the profiler configuration from Teamscale fails. */
+class AgentOptionReceiveException : Exception {
+ constructor(message: String?) : super(message)
+ constructor(message: String?, cause: Throwable?) : super(message, cause)
+}
\ No newline at end of file
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/configuration/ConfigurationViaTeamscale.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/configuration/ConfigurationViaTeamscale.kt
new file mode 100644
index 000000000..cb828c14e
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/configuration/ConfigurationViaTeamscale.kt
@@ -0,0 +1,163 @@
+package com.teamscale.jacoco.agent.configuration
+
+import com.fasterxml.jackson.core.JsonProcessingException
+import com.teamscale.client.ITeamscaleService
+import com.teamscale.client.JsonUtils
+import com.teamscale.client.ProcessInformation
+import com.teamscale.client.ProfilerConfiguration
+import com.teamscale.client.ProfilerInfo
+import com.teamscale.client.ProfilerRegistration
+import com.teamscale.client.TeamscaleServiceGenerator
+import com.teamscale.jacoco.agent.logging.LoggingUtils
+import com.teamscale.jacoco.agent.util.AgentUtils
+import com.teamscale.report.util.ILogger
+import okhttp3.HttpUrl
+import okhttp3.ResponseBody
+import retrofit2.Response
+import java.io.IOException
+import java.time.Duration
+import java.util.concurrent.Executors
+import java.util.concurrent.ThreadFactory
+import java.util.concurrent.TimeUnit
+
+/**
+ * Responsible for holding the configuration retrieved from Teamscale and sending regular heartbeat events to
+ * keep the profiler information in Teamscale up to date.
+ */
+class ConfigurationViaTeamscale(
+ private val teamscaleClient: ITeamscaleService,
+ profilerRegistration: ProfilerRegistration,
+ processInformation: ProcessInformation
+) {
+ /**
+ * The UUID that Teamscale assigned to this instance of the profiler during the registration. This ID needs to be
+ * used when communicating with Teamscale.
+ */
+ @JvmField
+ val profilerId = profilerRegistration.profilerId
+
+ private val profilerInfo = ProfilerInfo(processInformation, profilerRegistration.profilerConfiguration)
+
+ /** Returns the profiler configuration retrieved from Teamscale. */
+ val profilerConfiguration: ProfilerConfiguration?
+ get() = profilerInfo.profilerConfiguration
+
+ /**
+ * Starts a heartbeat thread and registers a shutdown hook.
+ *
+ *
+ * This spawns a new thread every minute which sends a heartbeat to Teamscale. It also registers a shutdown hook
+ * that unregisters the profiler from Teamscale.
+ */
+ fun startHeartbeatThreadAndRegisterShutdownHook() {
+ val executor = Executors.newSingleThreadScheduledExecutor { runnable ->
+ val thread = Thread(runnable)
+ thread.setDaemon(true)
+ thread
+ }
+
+ executor.scheduleAtFixedRate({ sendHeartbeat() }, 1, 1, TimeUnit.MINUTES)
+
+ Runtime.getRuntime().addShutdownHook(Thread {
+ executor.shutdownNow()
+ unregisterProfiler()
+ })
+ }
+
+ private fun sendHeartbeat() {
+ try {
+ val response = teamscaleClient.sendHeartbeat(profilerId!!, profilerInfo).execute()
+ if (!response.isSuccessful) {
+ LoggingUtils.getLogger(this)
+ .error("Failed to send heartbeat. Teamscale responded with: ${response.errorBody()?.string()}")
+ }
+ } catch (e: IOException) {
+ LoggingUtils.getLogger(this).error("Failed to send heartbeat to Teamscale!", e)
+ }
+ }
+
+ /** Unregisters the profiler in Teamscale (marks it as shut down). */
+ fun unregisterProfiler() {
+ try {
+ var response = teamscaleClient.unregisterProfiler(profilerId!!).execute()
+ if (response.code() == 405) {
+ @Suppress("DEPRECATION")
+ response = teamscaleClient.unregisterProfilerLegacy(profilerId).execute()
+ }
+ if (!response.isSuccessful) {
+ LoggingUtils.getLogger(this)
+ .error("Failed to unregister profiler. Teamscale responded with: ${response.errorBody()?.string()}")
+ }
+ } catch (e: IOException) {
+ LoggingUtils.getLogger(this).error("Failed to unregister profiler!", e)
+ }
+ }
+
+ companion object {
+ /**
+ * Two minute timeout. This is quite high to account for an eventual high load on the Teamscale server. This is a
+ * tradeoff between fast application startup and potentially missing test coverage.
+ */
+ private val LONG_TIMEOUT: Duration = Duration.ofMinutes(2)
+
+ /**
+ * Tries to retrieve the profiler configuration from Teamscale. In case retrieval fails the method throws a
+ * [AgentOptionReceiveException].
+ */
+ @JvmStatic
+ @Throws(AgentOptionReceiveException::class)
+ fun retrieve(
+ logger: ILogger,
+ configurationId: String?,
+ url: HttpUrl,
+ userName: String,
+ userAccessToken: String
+ ): ConfigurationViaTeamscale {
+ val teamscaleClient = TeamscaleServiceGenerator
+ .createService(url, userName, userAccessToken, AgentUtils.USER_AGENT, LONG_TIMEOUT, LONG_TIMEOUT)
+ try {
+ val processInformation = ProcessInformationRetriever(logger).processInformation
+ val response = teamscaleClient.registerProfiler(
+ configurationId,
+ processInformation
+ ).execute()
+ if (!response.isSuccessful) {
+ throw AgentOptionReceiveException(
+ "Failed to retrieve profiler configuration from Teamscale due to failed request. Http status: ${response.code()} Body: ${response.errorBody()?.string()}"
+ )
+ }
+
+ val body = response.body()
+ return parseProfilerRegistration(body!!, response, teamscaleClient, processInformation)
+ } catch (e: IOException) {
+ // we include the causing error message in this exception's message since this causes it to be printed
+ // to stderr which is much more helpful than just saying "something didn't work"
+ throw AgentOptionReceiveException(
+ "Failed to retrieve profiler configuration from Teamscale due to network error: ${
+ LoggingUtils.getStackTraceAsString(e)
+ }", e
+ )
+ }
+ }
+
+ @Throws(AgentOptionReceiveException::class, IOException::class)
+ private fun parseProfilerRegistration(
+ body: ResponseBody,
+ response: Response,
+ teamscaleClient: ITeamscaleService,
+ processInformation: ProcessInformation
+ ): ConfigurationViaTeamscale {
+ // We may only call this once
+ val bodyString = body.string()
+ try {
+ val registration = JsonUtils.deserialize(bodyString)
+ return ConfigurationViaTeamscale(teamscaleClient, registration, processInformation)
+ } catch (e: JsonProcessingException) {
+ throw AgentOptionReceiveException(
+ "Failed to retrieve profiler configuration from Teamscale due to invalid JSON. HTTP code: " + response.code() + " Response: " + bodyString,
+ e
+ )
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/configuration/ProcessInformationRetriever.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/configuration/ProcessInformationRetriever.kt
new file mode 100644
index 000000000..e4692acdd
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/configuration/ProcessInformationRetriever.kt
@@ -0,0 +1,53 @@
+package com.teamscale.jacoco.agent.configuration
+
+import com.teamscale.client.ProcessInformation
+import com.teamscale.report.util.ILogger
+import java.lang.management.ManagementFactory
+import java.net.InetAddress
+import java.net.UnknownHostException
+
+/**
+ * Is responsible for retrieving process information such as the host name and process ID.
+ */
+class ProcessInformationRetriever(private val logger: ILogger) {
+ /**
+ * Retrieves the process information, including the host name and process ID.
+ */
+ val processInformation: ProcessInformation
+ get() = ProcessInformation(hostName, pID, System.currentTimeMillis())
+
+ /**
+ * Retrieves the host name of the local machine.
+ */
+ private val hostName: String
+ get() {
+ try {
+ return InetAddress.getLocalHost().hostName
+ } catch (e: UnknownHostException) {
+ logger.error("Failed to determine hostname!", e)
+ return ""
+ }
+ }
+
+ /**
+ * Returns a string that *probably* contains the PID.
+ *
+ * On Java 9 there is an API to get the PID. But since we support Java 8, we may fall back to an undocumented API
+ * that at least contains the PID in most JVMs.
+ *
+ * See [This StackOverflow question](https://stackoverflow.com/questions/35842/how-can-a-java-program-get-its-own-process-id)
+ */
+ companion object {
+ val pID: String
+ get() {
+ try {
+ val processHandleClass = Class.forName("java.lang.ProcessHandle")
+ val processHandle = processHandleClass.getMethod("current").invoke(null)
+ val pid = processHandleClass.getMethod("pid").invoke(processHandle) as Long
+ return pid.toString()
+ } catch (_: ReflectiveOperationException) {
+ return ManagementFactory.getRuntimeMXBean().name
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/convert/ConvertCommand.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/convert/ConvertCommand.kt
new file mode 100644
index 000000000..2183de6f5
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/convert/ConvertCommand.kt
@@ -0,0 +1,156 @@
+/*-------------------------------------------------------------------------+
+| |
+| Copyright (c) 2009-2017 CQSE GmbH |
+| |
++-------------------------------------------------------------------------*/
+package com.teamscale.jacoco.agent.convert
+
+import com.beust.jcommander.Parameter
+import com.beust.jcommander.Parameters
+import com.teamscale.client.FileSystemUtils.ensureDirectoryExists
+import com.teamscale.client.StringUtils.isEmpty
+import com.teamscale.jacoco.agent.commandline.ICommand
+import com.teamscale.jacoco.agent.commandline.Validator
+import com.teamscale.jacoco.agent.options.ClasspathUtils
+import com.teamscale.jacoco.agent.options.FilePatternResolver
+import com.teamscale.jacoco.agent.util.Assertions
+import com.teamscale.report.EDuplicateClassFileBehavior
+import com.teamscale.report.util.CommandLineLogger
+import java.io.File
+import java.io.IOException
+
+/**
+ * Encapsulates all command line options for the convert command for parsing with [JCommander].
+ */
+@Parameters(
+ commandNames = ["convert"], commandDescription = "Converts a binary .exec coverage file to XML. " +
+ "Note that the XML report will only contain source file coverage information, but no class coverage."
+)
+class ConvertCommand : ICommand {
+ /** The directories and/or zips that contain all class files being profiled. */
+ @JvmField
+ @Parameter(
+ names = ["--class-dir", "--jar", "-c"], required = true, description = (""
+ + "The directories or zip/ear/jar/war/... files that contain the compiled Java classes being profiled."
+ + " Searches recursively, including inside zips. You may also supply a *.txt file with one path per line.")
+ )
+ var classDirectoriesOrZips = mutableListOf()
+
+ /**
+ * Wildcard include patterns to apply during JaCoCo's traversal of class files.
+ */
+ @Parameter(
+ names = ["--includes"], description = (""
+ + "Wildcard include patterns to apply to all found class file locations during JaCoCo's traversal of class files."
+ + " Note that zip contents are separated from zip files with @ and that you can filter only"
+ + " class files, not intermediate folders/zips. Use with great care as missing class files"
+ + " lead to broken coverage files! Turn on debug logging to see which locations are being filtered."
+ + " Defaults to no filtering. Excludes overrule includes.")
+ )
+ var locationIncludeFilters = mutableListOf()
+
+ /**
+ * Wildcard exclude patterns to apply during JaCoCo's traversal of class files.
+ */
+ @Parameter(
+ names = ["--excludes", "-e"], description = (""
+ + "Wildcard exclude patterns to apply to all found class file locations during JaCoCo's traversal of class files."
+ + " Note that zip contents are separated from zip files with @ and that you can filter only"
+ + " class files, not intermediate folders/zips. Use with great care as missing class files"
+ + " lead to broken coverage files! Turn on debug logging to see which locations are being filtered."
+ + " Defaults to no filtering. Excludes overrule includes.")
+ )
+ var locationExcludeFilters = mutableListOf()
+
+ /** The directory to write the XML traces to. */
+ @JvmField
+ @Parameter(
+ names = ["--in", "-i"], required = true, description = ("" + "The binary .exec file(s), test details and " +
+ "test executions to read. Can be a single file or a directory that is recursively scanned for relevant files.")
+ )
+ var inputFiles = mutableListOf()
+
+ /** The directory to write the XML traces to. */
+ @JvmField
+ @Parameter(
+ names = ["--out", "-o"], required = true, description = (""
+ + "The file to write the generated XML report to.")
+ )
+ var outputFile = ""
+
+ /** Whether to ignore duplicate, non-identical class files. */
+ @Parameter(
+ names = ["--duplicates", "-d"], arity = 1, description = (""
+ + "Whether to ignore duplicate, non-identical class files."
+ + " This is discouraged and may result in incorrect coverage files. Defaults to WARN. " +
+ "Options are FAIL, WARN and IGNORE.")
+ )
+ var duplicateClassFileBehavior = EDuplicateClassFileBehavior.WARN
+
+ /** Whether to ignore uncovered class files. */
+ @Parameter(
+ names = ["--ignore-uncovered-classes"], required = false, arity = 1, description = (""
+ + "Whether to ignore uncovered classes."
+ + " These classes will not be part of the XML report at all, making it considerably smaller in some cases. Defaults to false.")
+ )
+ var shouldIgnoreUncoveredClasses = false
+
+ /** Whether testwise coverage or jacoco coverage should be generated. */
+ @Parameter(
+ names = ["--testwise-coverage", "-t"], required = false, arity = 0, description = "Whether testwise " +
+ "coverage or jacoco coverage should be generated."
+ )
+ var shouldGenerateTestwiseCoverage = false
+
+ /** After how many tests testwise coverage should be split into multiple reports. */
+ @Parameter(
+ names = ["--split-after", "-s"], required = false, arity = 1, description = "After how many tests " +
+ "testwise coverage should be split into multiple reports (Default is 5000)."
+ )
+ val splitAfter = 5000
+
+ @Throws(IOException::class)
+ fun getClassDirectoriesOrZips(): List = ClasspathUtils
+ .resolveClasspathTextFiles(
+ "class-dir", FilePatternResolver(CommandLineLogger()),
+ classDirectoriesOrZips
+ )
+
+ fun getInputFiles() = inputFiles.map { File(it) }
+ fun getOutputFile() = File(outputFile)
+
+ /** Makes sure the arguments are valid. */
+ override fun validate() = Validator().apply {
+ val classDirectoriesOrZips = mutableListOf()
+ ensure { classDirectoriesOrZips.addAll(getClassDirectoriesOrZips()) }
+ isFalse(
+ classDirectoriesOrZips.isEmpty(),
+ "You must specify at least one directory or zip that contains class files"
+ )
+ classDirectoriesOrZips.forEach { path ->
+ isTrue(path.exists(), "Path '$path' does not exist")
+ isTrue(path.canRead(), "Path '$path' is not readable")
+ }
+ getInputFiles().forEach { inputFile ->
+ isTrue(inputFile.exists() && inputFile.canRead(), "Cannot read the input file $inputFile")
+ }
+ ensure {
+ Assertions.isFalse(isEmpty(outputFile), "You must specify an output file")
+ val outputDir = getOutputFile().getAbsoluteFile().getParentFile()
+ ensureDirectoryExists(outputDir)
+ Assertions.isTrue(outputDir.canWrite(), "Path '$outputDir' is not writable")
+ }
+ }
+
+ /** {@inheritDoc} */
+ @Throws(Exception::class)
+ override fun run() {
+ Converter(this).apply {
+ if (shouldGenerateTestwiseCoverage) {
+ runTestwiseCoverageReportGeneration()
+ } else {
+ runJaCoCoReportGeneration()
+ }
+ }
+ }
+}
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/convert/Converter.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/convert/Converter.kt
new file mode 100644
index 000000000..b5fedefdd
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/convert/Converter.kt
@@ -0,0 +1,96 @@
+package com.teamscale.jacoco.agent.convert
+
+import com.teamscale.client.TestDetails
+import com.teamscale.jacoco.agent.benchmark
+import com.teamscale.jacoco.agent.logging.LoggingUtils
+import com.teamscale.jacoco.agent.options.AgentOptionParseException
+import com.teamscale.report.ReportUtils
+import com.teamscale.report.ReportUtils.listFiles
+import com.teamscale.report.jacoco.EmptyReportException
+import com.teamscale.report.jacoco.JaCoCoXmlReportGenerator
+import com.teamscale.report.testwise.ETestArtifactFormat
+import com.teamscale.report.testwise.TestwiseCoverageReportWriter
+import com.teamscale.report.testwise.jacoco.JaCoCoTestwiseReportGenerator
+import com.teamscale.report.testwise.model.TestExecution
+import com.teamscale.report.testwise.model.factory.TestInfoFactory
+import com.teamscale.report.util.ClasspathWildcardIncludeFilter
+import com.teamscale.report.util.CommandLineLogger
+import java.io.IOException
+import java.lang.String
+import java.nio.file.Paths
+import kotlin.Array
+import kotlin.Throws
+import kotlin.use
+
+/** Converts one .exec binary coverage file to XML. */
+class Converter
+/** Constructor. */(
+ /** The command line arguments. */
+ private val arguments: ConvertCommand
+) {
+ /** Converts one .exec binary coverage file to XML. */
+ @Throws(IOException::class)
+ fun runJaCoCoReportGeneration() {
+ val logger = LoggingUtils.getLogger(this)
+ val generator = JaCoCoXmlReportGenerator(
+ arguments.getClassDirectoriesOrZips(),
+ wildcardIncludeExcludeFilter,
+ arguments.duplicateClassFileBehavior,
+ arguments.shouldIgnoreUncoveredClasses,
+ LoggingUtils.wrap(logger)
+ )
+
+ val jacocoExecutionDataList = listFiles(ETestArtifactFormat.JACOCO, arguments.getInputFiles())
+ try {
+ benchmark("Generating the XML report") {
+ generator.convertExecFilesToReport(jacocoExecutionDataList, Paths.get(arguments.outputFile).toFile())
+ }
+ } catch (e: EmptyReportException) {
+ logger.warn("Converted report was empty.", e)
+ }
+ }
+
+ /** Converts one .exec binary coverage file, test details and test execution files to JSON testwise coverage. */
+ @Throws(IOException::class, AgentOptionParseException::class)
+ fun runTestwiseCoverageReportGeneration() {
+ val testDetails = ReportUtils.readObjects(
+ ETestArtifactFormat.TEST_LIST,
+ Array::class.java,
+ arguments.getInputFiles()
+ )
+ val testExecutions = ReportUtils.readObjects(
+ ETestArtifactFormat.TEST_EXECUTION,
+ Array::class.java,
+ arguments.getInputFiles()
+ )
+
+ val jacocoExecutionDataList = listFiles(ETestArtifactFormat.JACOCO, arguments.getInputFiles())
+ val logger = CommandLineLogger()
+
+ val generator = JaCoCoTestwiseReportGenerator(
+ arguments.getClassDirectoriesOrZips(),
+ this.wildcardIncludeExcludeFilter,
+ arguments.duplicateClassFileBehavior,
+ logger
+ )
+
+ benchmark("Generating the testwise coverage report") {
+ logger.info("Writing report with ${testDetails.size} Details/${testExecutions.size} Results")
+ TestwiseCoverageReportWriter(
+ TestInfoFactory(testDetails, testExecutions),
+ arguments.getOutputFile(),
+ arguments.splitAfter, null
+ ).use { coverageWriter ->
+ jacocoExecutionDataList.forEach { executionDataFile ->
+ generator.convertAndConsume(executionDataFile, coverageWriter)
+ }
+ }
+ }
+ }
+
+ private val wildcardIncludeExcludeFilter: ClasspathWildcardIncludeFilter
+ get() = ClasspathWildcardIncludeFilter(
+ String.join(":", arguments.locationIncludeFilters),
+ String.join(":", arguments.locationExcludeFilters)
+ )
+}
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/DebugLogDirectoryPropertyDefiner.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/DebugLogDirectoryPropertyDefiner.kt
new file mode 100644
index 000000000..876e989f2
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/DebugLogDirectoryPropertyDefiner.kt
@@ -0,0 +1,14 @@
+package com.teamscale.jacoco.agent.logging
+
+import java.nio.file.Path
+
+/** Defines a property that contains the path to which log files should be written. */
+class DebugLogDirectoryPropertyDefiner : LogDirectoryPropertyDefiner() {
+ override fun getPropertyValue() =
+ filePath?.resolve("logs")?.toAbsolutePath()?.toString() ?: super.getPropertyValue()
+
+ companion object {
+ /** File path for debug logging. */ /* package */
+ var filePath: Path? = null
+ }
+}
\ No newline at end of file
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/LogDirectoryPropertyDefiner.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/LogDirectoryPropertyDefiner.kt
new file mode 100644
index 000000000..c587a45f0
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/LogDirectoryPropertyDefiner.kt
@@ -0,0 +1,10 @@
+package com.teamscale.jacoco.agent.logging
+
+import ch.qos.logback.core.PropertyDefinerBase
+import com.teamscale.jacoco.agent.util.AgentUtils
+
+/** Defines a property that contains the default path to which log files should be written. */
+open class LogDirectoryPropertyDefiner : PropertyDefinerBase() {
+ override fun getPropertyValue() =
+ AgentUtils.mainTempDirectory.resolve("logs").toAbsolutePath().toString()
+}
\ No newline at end of file
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/LogToTeamscaleAppender.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/LogToTeamscaleAppender.kt
new file mode 100644
index 000000000..5e3f4c0cd
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/LogToTeamscaleAppender.kt
@@ -0,0 +1,183 @@
+package com.teamscale.jacoco.agent.logging
+
+import ch.qos.logback.classic.Logger
+import ch.qos.logback.classic.LoggerContext
+import ch.qos.logback.classic.spi.ILoggingEvent
+import ch.qos.logback.core.AppenderBase
+import ch.qos.logback.core.status.ErrorStatus
+import com.teamscale.client.ITeamscaleService
+import com.teamscale.client.ProfilerLogEntry
+import com.teamscale.jacoco.agent.options.AgentOptions
+import java.net.ConnectException
+import java.time.Duration
+import java.util.Collections
+import java.util.IdentityHashMap
+import java.util.LinkedHashSet
+import java.util.concurrent.CompletableFuture
+import java.util.concurrent.Executors
+import java.util.concurrent.ScheduledExecutorService
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.function.BiConsumer
+
+/**
+ * Custom log appender that sends logs to Teamscale; it buffers log that were not sent due to connection issues and
+ * sends them later.
+ */
+class LogToTeamscaleAppender : AppenderBase() {
+ /** The unique ID of the profiler */
+ private var profilerId: String? = null
+
+ /**
+ * Buffer for unsent logs. We use a set here to allow for removing entries fast after sending them to Teamscale was
+ * successful.
+ */
+ private val logBuffer = LinkedHashSet()
+
+ /** Scheduler for sending logs after the configured time interval */
+ private val scheduler: ScheduledExecutorService = Executors.newScheduledThreadPool(1) { r ->
+ // Make the thread a daemon so that it does not prevent the JVM from terminating.
+ val t = Executors.defaultThreadFactory().newThread(r)
+ t.setDaemon(true)
+ t
+ }
+
+ /** Active log flushing threads */
+ private val activeLogFlushes: MutableSet> =
+ Collections.newSetFromMap(IdentityHashMap())
+
+ /** Is there a flush going on right now? */
+ private val isFlusing = AtomicBoolean(false)
+
+ override fun start() {
+ super.start()
+ scheduler.scheduleAtFixedRate({
+ synchronized(activeLogFlushes) {
+ activeLogFlushes.removeIf { it.isDone }
+ if (activeLogFlushes.isEmpty()) flush()
+ }
+ }, FLUSH_INTERVAL.toMillis(), FLUSH_INTERVAL.toMillis(), TimeUnit.MILLISECONDS)
+ }
+
+ override fun append(eventObject: ILoggingEvent) {
+ synchronized(logBuffer) {
+ logBuffer.add(formatLog(eventObject))
+ if (logBuffer.size >= BATCH_SIZE) flush()
+ }
+ }
+
+ private fun formatLog(eventObject: ILoggingEvent): ProfilerLogEntry {
+ val trace = LoggingUtils.getStackTraceFromEvent(eventObject)
+ val timestamp = eventObject.timeStamp
+ val message = eventObject.formattedMessage
+ val severity = eventObject.level.toString()
+ return ProfilerLogEntry(timestamp, message, trace, severity)
+ }
+
+ private fun flush() {
+ sendLogs()
+ }
+
+ /** Send logs in a separate thread */
+ private fun sendLogs() {
+ synchronized(activeLogFlushes) {
+ activeLogFlushes.add(CompletableFuture.runAsync {
+ if (isFlusing.compareAndSet(false, true)) {
+ try {
+ val client = teamscaleClient ?: return@runAsync // There might be no connection configured.
+
+ val logsToSend: MutableList
+ synchronized(logBuffer) {
+ logsToSend = logBuffer.toMutableList()
+ }
+
+ val call = client.postProfilerLog(profilerId!!, logsToSend)
+ val response = call.execute()
+ check(response.isSuccessful) { "Failed to send log: HTTP error code : ${response.code()}" }
+
+ synchronized(logBuffer) {
+ // Removing the logs that have been sent after the fact.
+ // This handles problems with lost network connections.
+ logBuffer.removeAll(logsToSend.toSet())
+ }
+ } catch (e: Exception) {
+ // We do not report on exceptions here.
+ if (e !is ConnectException) {
+ addStatus(ErrorStatus("Sending logs to Teamscale failed: ${e.message}", this, e))
+ }
+ } finally {
+ isFlusing.set(false)
+ }
+ }
+ }.whenComplete(BiConsumer { _, _ ->
+ synchronized(activeLogFlushes) {
+ activeLogFlushes.removeIf { it.isDone }
+ }
+ }))
+ }
+ }
+
+ override fun stop() {
+ // Already flush here once to make sure that we do not miss too much.
+ flush()
+
+ scheduler.shutdown()
+ try {
+ if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) {
+ scheduler.shutdownNow()
+ }
+ } catch (_: InterruptedException) {
+ scheduler.shutdownNow()
+ }
+
+ // A final flush after the scheduler has been shut down.
+ flush()
+
+ // Block until all flushes are done
+ CompletableFuture.allOf(*activeLogFlushes.toTypedArray()).join()
+
+ super.stop()
+ }
+
+ fun setTeamscaleClient(teamscaleClient: ITeamscaleService?) {
+ Companion.teamscaleClient = teamscaleClient
+ }
+
+ fun setProfilerId(profilerId: String) {
+ this.profilerId = profilerId
+ }
+
+ companion object {
+ /** Flush the logs after N elements are in the queue */
+ private const val BATCH_SIZE = 50
+
+ /** Flush the logs in the given time interval */
+ private val FLUSH_INTERVAL: Duration = Duration.ofSeconds(3)
+
+ /** The service client for sending logs to Teamscale */
+ private var teamscaleClient: ITeamscaleService? = null
+
+ /**
+ * Add the [LogToTeamscaleAppender] to the logging configuration and
+ * enable/start it.
+ */
+ fun addTeamscaleAppenderTo(context: LoggerContext, agentOptions: AgentOptions): Boolean {
+ val client = agentOptions.createTeamscaleClient(false)
+ if (client == null || agentOptions.configurationViaTeamscale == null) {
+ return false
+ }
+
+ context.getLogger(Logger.ROOT_LOGGER_NAME).apply {
+ val logToTeamscaleAppender = LogToTeamscaleAppender().apply {
+ setContext(context)
+ setProfilerId(agentOptions.configurationViaTeamscale!!.profilerId!!)
+ setTeamscaleClient(client.service)
+ start()
+ }
+ addAppender(logToTeamscaleAppender)
+ }
+
+ return true
+ }
+ }
+}
\ No newline at end of file
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/LoggingUtils.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/LoggingUtils.kt
new file mode 100644
index 000000000..0e0a244a9
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/LoggingUtils.kt
@@ -0,0 +1,124 @@
+package com.teamscale.jacoco.agent.logging
+
+import ch.qos.logback.classic.LoggerContext
+import ch.qos.logback.classic.joran.JoranConfigurator
+import ch.qos.logback.classic.spi.ILoggingEvent
+import ch.qos.logback.classic.spi.ThrowableProxy
+import ch.qos.logback.classic.spi.ThrowableProxyUtil
+import ch.qos.logback.core.joran.spi.JoranException
+import ch.qos.logback.core.util.StatusPrinter
+import com.teamscale.jacoco.agent.Agent
+import com.teamscale.jacoco.agent.util.NullOutputStream
+import com.teamscale.report.util.ILogger
+import org.slf4j.Logger
+import org.slf4j.LoggerFactory
+import java.io.FileInputStream
+import java.io.IOException
+import java.io.InputStream
+import java.io.PrintStream
+import java.lang.AutoCloseable
+import java.nio.file.Path
+
+/**
+ * Helps initialize the logging framework properly.
+ */
+object LoggingUtils {
+ /** Returns a logger for the given object's class. */
+ @JvmStatic
+ fun getLogger(obj: Any): Logger = LoggerFactory.getLogger(obj.javaClass)
+
+ /** Returns a logger for the given class. */
+ @JvmStatic
+ fun getLogger(obj: Class<*>): Logger = LoggerFactory.getLogger(obj)
+
+ /** Initializes the logging to the default configured in the Jar. */
+ fun initializeDefaultLogging(): LoggingResources {
+ val stream = Agent::class.java.getResourceAsStream("logback-default.xml")
+ reconfigureLoggerContext(stream)
+ return LoggingResources()
+ }
+
+ /**
+ * Returns the logger context.
+ */
+ val loggerContext: LoggerContext
+ get() = LoggerFactory.getILoggerFactory() as LoggerContext
+
+ /**
+ * Extracts the stack trace from an ILoggingEvent using ThrowableProxyUtil.
+ *
+ * @param event the logging event containing the exception
+ * @return the stack trace as a String, or null if no exception is associated
+ */
+ fun getStackTraceFromEvent(event: ILoggingEvent) =
+ event.throwableProxy?.let { ThrowableProxyUtil.asString(it) }
+
+ /**
+ * Converts a Throwable to its stack trace as a String.
+ *
+ * @param throwable the throwable to convert
+ * @return the stack trace as a String
+ */
+ @JvmStatic
+ fun getStackTraceAsString(throwable: Throwable?) =
+ throwable?.let { ThrowableProxyUtil.asString(ThrowableProxy(it)) }
+
+ /**
+ * Reconfigures the logger context to use the configuration XML from the given input stream. Cf. [https://logback.qos.ch/manual/configuration.html](https://logback.qos.ch/manual/configuration.html)
+ */
+ private fun reconfigureLoggerContext(stream: InputStream?) {
+ StatusPrinter.setPrintStream(PrintStream(NullOutputStream()))
+ try {
+ val configurator = JoranConfigurator()
+ configurator.setContext(loggerContext)
+ loggerContext.reset()
+ configurator.doConfigure(stream)
+ } catch (_: JoranException) {
+ // StatusPrinter will handle this
+ }
+ StatusPrinter.printInCaseOfErrorsOrWarnings(loggerContext)
+ }
+
+ /**
+ * Initializes the logging from the given file. If that is `null`, uses [ ][.initializeDefaultLogging] instead.
+ */
+ @Throws(IOException::class)
+ fun initializeLogging(loggingConfigFile: Path?): LoggingResources {
+ if (loggingConfigFile == null) {
+ return initializeDefaultLogging()
+ }
+
+ reconfigureLoggerContext(FileInputStream(loggingConfigFile.toFile()))
+ return LoggingResources()
+ }
+
+ /** Initializes debug logging. */
+ fun initializeDebugLogging(logDirectory: Path?): LoggingResources {
+ if (logDirectory != null) {
+ DebugLogDirectoryPropertyDefiner.filePath = logDirectory
+ }
+ val stream = Agent::class.java.getResourceAsStream("logback-default-debugging.xml")
+ reconfigureLoggerContext(stream)
+ return LoggingResources()
+ }
+
+ /** Wraps the given slf4j logger into an [com.teamscale.report.util.ILogger]. */
+ @JvmStatic
+ fun wrap(logger: Logger): ILogger {
+ return object : ILogger {
+ override fun debug(message: String) = logger.debug(message)
+ override fun info(message: String) = logger.info(message)
+ override fun warn(message: String) = logger.warn(message)
+ override fun warn(message: String, throwable: Throwable?) = logger.warn(message, throwable)
+ override fun error(throwable: Throwable) = logger.error(throwable.message, throwable)
+ override fun error(message: String, throwable: Throwable?) = logger.error(message, throwable)
+ }
+ }
+
+ /** Class to use with try-with-resources to close the logging framework's resources. */
+ class LoggingResources : AutoCloseable {
+ override fun close() {
+ loggerContext.stop()
+ }
+ }
+}
\ No newline at end of file
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/AgentOptionParseException.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/AgentOptionParseException.kt
new file mode 100644
index 000000000..c3839b398
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/AgentOptionParseException.kt
@@ -0,0 +1,10 @@
+package com.teamscale.jacoco.agent.options
+
+/**
+ * Thrown if option parsing fails.
+ */
+class AgentOptionParseException : Exception {
+ constructor(message: String?) : super(message)
+ constructor(e: Exception) : super(e.message, e)
+ constructor(message: String?, cause: Throwable?) : super(message, cause)
+}
\ No newline at end of file
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/AgentOptions.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/AgentOptions.kt
new file mode 100644
index 000000000..a25f081b2
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/AgentOptions.kt
@@ -0,0 +1,712 @@
+package com.teamscale.jacoco.agent.options
+
+import com.teamscale.client.*
+import com.teamscale.client.FileSystemUtils.ensureDirectoryExists
+import com.teamscale.client.FileSystemUtils.getFileExtension
+import com.teamscale.client.StringUtils.isEmpty
+import com.teamscale.jacoco.agent.commandline.Validator
+import com.teamscale.jacoco.agent.commit_resolution.git_properties.CommitInfo
+import com.teamscale.jacoco.agent.commit_resolution.git_properties.GitMultiProjectPropertiesLocator
+import com.teamscale.jacoco.agent.commit_resolution.git_properties.GitPropertiesLocatingTransformer
+import com.teamscale.jacoco.agent.commit_resolution.git_properties.GitPropertiesLocatorUtils.getCommitInfoFromGitProperties
+import com.teamscale.jacoco.agent.commit_resolution.git_properties.GitPropertiesLocatorUtils.getProjectRevisionsFromGitProperties
+import com.teamscale.jacoco.agent.commit_resolution.git_properties.GitSingleProjectPropertiesLocator
+import com.teamscale.jacoco.agent.commit_resolution.git_properties.GitSingleProjectPropertiesLocator.DataExtractor
+import com.teamscale.jacoco.agent.commit_resolution.sapnwdi.NwdiMarkerClassLocatingTransformer
+import com.teamscale.jacoco.agent.configuration.ConfigurationViaTeamscale
+import com.teamscale.jacoco.agent.options.AgentOptions.Companion.GIT_PROPERTIES_COMMIT_DATE_FORMAT_OPTION
+import com.teamscale.jacoco.agent.options.AgentOptions.Companion.GIT_PROPERTIES_JAR_OPTION
+import com.teamscale.jacoco.agent.options.sapnwdi.DelayedSapNwdiMultiUploader
+import com.teamscale.jacoco.agent.options.sapnwdi.SapNwdiApplication
+import com.teamscale.jacoco.agent.upload.IUploader
+import com.teamscale.jacoco.agent.upload.LocalDiskUploader
+import com.teamscale.jacoco.agent.upload.UploaderException
+import com.teamscale.jacoco.agent.upload.artifactory.ArtifactoryConfig
+import com.teamscale.jacoco.agent.upload.artifactory.ArtifactoryUploader
+import com.teamscale.jacoco.agent.upload.azure.AzureFileStorageConfig
+import com.teamscale.jacoco.agent.upload.azure.AzureFileStorageUploader
+import com.teamscale.jacoco.agent.upload.delay.DelayedUploader
+import com.teamscale.jacoco.agent.upload.teamscale.DelayedTeamscaleMultiProjectUploader
+import com.teamscale.jacoco.agent.upload.teamscale.TeamscaleConfig
+import com.teamscale.jacoco.agent.upload.teamscale.TeamscaleUploader
+import com.teamscale.jacoco.agent.util.AgentUtils
+import com.teamscale.jacoco.agent.util.AgentUtils.mainTempDirectory
+import com.teamscale.report.EDuplicateClassFileBehavior
+import com.teamscale.report.util.ClasspathWildcardIncludeFilter
+import com.teamscale.report.util.ILogger
+import java.io.File
+import java.io.IOException
+import java.lang.instrument.Instrumentation
+import java.nio.file.Path
+import java.nio.file.Paths
+import java.time.LocalDateTime
+import java.time.format.DateTimeFormatter
+import java.util.*
+import java.util.function.BiFunction
+import java.util.function.Function
+import java.util.regex.Pattern
+import kotlin.io.path.exists
+import kotlin.io.path.isReadable
+import kotlin.io.path.isRegularFile
+import kotlin.math.max
+
+/**
+ * Parses agent command line options.
+ */
+open class AgentOptions(private val logger: ILogger) {
+ /** See [GIT_PROPERTIES_JAR_OPTION] */
+ @JvmField
+ var gitPropertiesJar: File? = null
+
+ /**
+ * Related to [GIT_PROPERTIES_COMMIT_DATE_FORMAT_OPTION]
+ */
+ @JvmField
+ var gitPropertiesCommitTimeFormat: DateTimeFormatter? = null
+
+ /**
+ * The original options passed to the agent.
+ */
+ @JvmField
+ var originalOptionsString: String? = null
+
+ /** Whether debug logging is active or not. */
+ @JvmField
+ var isDebugLogging: Boolean = false
+
+ /** Explicitly defined log file. */
+ @JvmField
+ var debugLogDirectory: Path? = null
+
+ /**
+ * The directories and/or zips that contain all class files being profiled. Never null. If this is empty, classes
+ * should be dumped to a temporary directory which should be used as the class-dir.
+ */
+ @JvmField
+ var classDirectoriesOrZips = mutableListOf()
+
+ /**
+ * The logging configuration file.
+ */
+ @JvmField
+ var loggingConfig: Path? = null
+
+ /**
+ * The directory to write the XML traces to.
+ */
+ var outputDirectory: Path? = null
+
+ /** Contains the options related to teamscale-specific proxy settings for http. */
+ var teamscaleProxyOptionsForHttp: TeamscaleProxyOptions?
+
+ /** Contains the options related to teamscale-specific proxy settings for https. */
+ var teamscaleProxyOptionsForHttps: TeamscaleProxyOptions?
+
+ /** Additional metadata files to upload together with the coverage XML. */
+ @JvmField
+ var additionalMetaDataFiles = listOf()
+
+ /** Whether the agent should be run in testwise coverage mode or normal mode. */
+ @JvmField
+ var mode = EMode.NORMAL
+
+ /** The interval in minutes for dumping XML data. */
+ @JvmField
+ var dumpIntervalInMinutes = 480
+
+ /** Whether to dump coverage when the JVM shuts down. */
+ @JvmField
+ var shouldDumpOnExit = true
+
+ /**
+ * Whether to search directories and jar files recursively for git.properties files
+ */
+ @JvmField
+ var searchGitPropertiesRecursively = true
+
+ /**
+ * Whether to validate SSL certificates, defaults to true.
+ */
+ @JvmField
+ var validateSsl = true
+
+ /**
+ * Whether to ignore duplicate, non-identical class files.
+ */
+ @JvmField
+ var duplicateClassFileBehavior = EDuplicateClassFileBehavior.WARN
+
+ /**
+ * Include patterns for fully qualified class names to pass on to JaCoCo. See [org.jacoco.core.runtime.WildcardMatcher] for the
+ * pattern syntax. Individual patterns must be separated by ":".
+ */
+ @JvmField
+ var jacocoIncludes: String? = null
+
+ /**
+ * Exclude patterns for fully qualified class names to pass on to JaCoCo. See [org.jacoco.core.runtime.WildcardMatcher] for the
+ * pattern syntax. Individual patterns must be separated by ":".
+ */
+ @JvmField
+ var jacocoExcludes = DEFAULT_EXCLUDES
+
+ /**
+ * Additional user-provided options to pass to JaCoCo.
+ */
+ @JvmField
+ var additionalJacocoOptions = mutableListOf>()
+
+ /**
+ * The teamscale server to which coverage should be uploaded.
+ */
+ @JvmField
+ var teamscaleServer = TeamscaleServer()
+
+ /**
+ * How testwise coverage should be handled in test-wise mode.
+ */
+ @JvmField
+ var testwiseCoverageMode = ETestwiseCoverageMode.EXEC_FILE
+
+ /**
+ * Returns the port at which the http server should listen for test execution information or null if disabled.
+ */
+ @JvmField
+ var httpServerPort: Int? = null
+
+ /**
+ * Whether classes without coverage should be skipped from the XML report.
+ */
+ @JvmField
+ var ignoreUncoveredClasses = false
+
+ /**
+ * The configuration necessary to upload files to an azure file storage
+ */
+ @JvmField
+ var artifactoryConfig = ArtifactoryConfig()
+
+ /**
+ * The configuration necessary to upload files to an azure file storage
+ */
+ @JvmField
+ var azureFileStorageConfig = AzureFileStorageConfig()
+
+ /**
+ * The configuration necessary when used in an SAP NetWeaver Java environment.
+ */
+ @JvmField
+ var sapNetWeaverJavaApplications = listOf()
+
+ /**
+ * Whether to obfuscate security related configuration options when dumping them into the log or onto the console or
+ * not.
+ */
+ @JvmField
+ var obfuscateSecurityRelatedOutputs = true
+
+ /**
+ * Helper class that holds the process information, Teamscale client and profiler configuration and allows to
+ * continuously update the profiler's info in Teamscale in the background via
+ * [ConfigurationViaTeamscale.startHeartbeatThreadAndRegisterShutdownHook].
+ */
+ @JvmField
+ var configurationViaTeamscale: ConfigurationViaTeamscale? = null
+
+ init {
+ setParentOutputDirectory(mainTempDirectory.resolve("coverage"))
+ teamscaleProxyOptionsForHttp = TeamscaleProxyOptions(
+ ProxySystemProperties.Protocol.HTTP, logger
+ )
+ teamscaleProxyOptionsForHttps = TeamscaleProxyOptions(
+ ProxySystemProperties.Protocol.HTTPS, logger
+ )
+ }
+
+ /**
+ * Remove parts of the API key for security reasons from the options string. String is used for logging purposes.
+ *
+ * Given, for example, "config-file=jacocoagent.properties,teamscale-access-token=unlYgehaYYYhbPAegNWV3WgjOzxkmNHn"
+ * we produce a string with obfuscation:
+ * "config-file=jacocoagent.properties,teamscale-access-token=************mNHn"
+ */
+ val obfuscatedOptionsString: String?
+ get() {
+ val original = originalOptionsString ?: return ""
+
+ val pattern = Pattern.compile("(.*-access-token=)([^,]+)(.*)")
+ val match = pattern.matcher(original)
+ if (match.find()) {
+ val apiKey = match.group(2)
+ val obfuscatedApiKey = "************${
+ apiKey.substring(
+ max(
+ 0,
+ apiKey.length - 4
+ )
+ )
+ }"
+ return "${match.group(1)}$obfuscatedApiKey${match.group(3)}"
+ }
+
+ return originalOptionsString
+ }
+
+ /**
+ * Validates the options and returns a validator with all validation errors.
+ */
+ val validator: Validator
+ get() = Validator().apply {
+ validateFilePaths()
+ validateLoggingConfig()
+ validateTeamscaleUploadConfig()
+ validateUploadConfig()
+ validateSapNetWeaverConfig()
+ if (useTestwiseCoverageMode()) {
+ validateTestwiseCoverageConfig()
+ }
+ }
+
+ private fun Validator.validateFilePaths() {
+ classDirectoriesOrZips.forEach { path ->
+ isTrue(path.exists(), "Path '$path' does not exist")
+ isTrue(path.canRead(), "Path '$path' is not readable")
+ }
+ }
+
+ private fun Validator.validateLoggingConfig() {
+ val loggingConfig = loggingConfig ?: return
+ ensure {
+ isTrue(
+ loggingConfig.exists(),
+ "The path provided for the logging configuration does not exist: $loggingConfig"
+ )
+ isTrue(
+ loggingConfig.isRegularFile(),
+ "The path provided for the logging configuration is not a file: $loggingConfig"
+ )
+ isTrue(
+ loggingConfig.isReadable(),
+ "The file provided for the logging configuration is not readable: $loggingConfig"
+ )
+ isTrue(
+ "xml".equals(getFileExtension(loggingConfig.toFile()), ignoreCase = true),
+ "The logging configuration file must have the file extension .xml and be a valid XML file"
+ )
+ }
+ }
+
+ private fun Validator.validateTeamscaleUploadConfig() {
+ isTrue(
+ teamscaleServer.hasAllFieldsNull() || teamscaleServer.canConnectToTeamscale() || teamscaleServer.isConfiguredForSingleProjectTeamscaleUpload || teamscaleServer.isConfiguredForMultiProjectUpload,
+ "You did provide some options prefixed with 'teamscale-', but not all required ones!"
+ )
+ isFalse(
+ teamscaleServer.isConfiguredForMultiProjectUpload && (teamscaleServer.revision != null
+ || teamscaleServer.commit != null),
+ "You tried to provide a commit to upload to directly. This is not possible, since you" +
+ " did not provide the 'teamscale-project' to upload to. Please either specify the 'teamscale-project'" +
+ " property, or provide the respective projects and commits via all the profiled Jar/War/Ear/...s' " +
+ " git.properties files."
+ )
+ isTrue(
+ teamscaleServer.revision == null || teamscaleServer.commit == null,
+ "'" + TeamscaleConfig.TEAMSCALE_REVISION_OPTION + "' and '" + TeamscaleConfig.TEAMSCALE_REVISION_MANIFEST_JAR_OPTION + "' are incompatible with '" + TeamscaleConfig.TEAMSCALE_COMMIT_OPTION + "' and '" +
+ TeamscaleConfig.TEAMSCALE_COMMIT_MANIFEST_JAR_OPTION + "'."
+ )
+ isTrue(
+ teamscaleServer.project == null || teamscaleServer.partition != null,
+ "You configured a 'teamscale-project' but no 'teamscale-partition' to upload to."
+ )
+ }
+
+ private fun Validator.validateUploadConfig() {
+ isTrue(
+ (artifactoryConfig.hasAllRequiredFieldsSet() || artifactoryConfig
+ .hasAllRequiredFieldsNull()),
+ String.format(
+ "If you want to upload data to Artifactory you need to provide " +
+ "'%s', '%s' and an authentication method (either '%s' and '%s' or '%s') ",
+ ArtifactoryConfig.ARTIFACTORY_URL_OPTION,
+ ArtifactoryConfig.ARTIFACTORY_PARTITION,
+ ArtifactoryConfig.ARTIFACTORY_USER_OPTION, ArtifactoryConfig.ARTIFACTORY_PASSWORD_OPTION,
+ ArtifactoryConfig.ARTIFACTORY_API_KEY_OPTION
+ )
+ )
+ isTrue(
+ (azureFileStorageConfig.hasAllRequiredFieldsSet() || azureFileStorageConfig
+ .hasAllRequiredFieldsNull()),
+ "If you want to upload data to an Azure file storage you need to provide both " +
+ "'azure-url' and 'azure-key' "
+ )
+ val configuredStores = listOf(
+ artifactoryConfig.hasAllRequiredFieldsSet(), azureFileStorageConfig.hasAllRequiredFieldsSet(),
+ teamscaleServer.isConfiguredForSingleProjectTeamscaleUpload,
+ teamscaleServer.isConfiguredForMultiProjectUpload
+ ).count { x -> x }
+
+ isTrue(
+ configuredStores <= 1, "You cannot configure multiple upload stores, " +
+ "such as a Teamscale instance, upload URL, Azure file storage or artifactory"
+ )
+ }
+
+ private fun Validator.validateSapNetWeaverConfig() {
+ if (sapNetWeaverJavaApplications.isEmpty()) return
+
+ isTrue(
+ teamscaleServer.project == null,
+ "You provided an SAP NWDI applications config and a teamscale-project. This is not allowed. " +
+ "The project must be specified via sap-nwdi-applications!"
+ )
+ isTrue(
+ teamscaleServer.project != null || teamscaleServer.isConfiguredForMultiProjectUpload,
+ "You provided an SAP NWDI applications config, but the 'teamscale-' upload options are incomplete."
+ )
+ }
+
+ private fun Validator.validateTestwiseCoverageConfig() {
+ isTrue(
+ httpServerPort != null,
+ "You use 'mode=testwise' but did not specify the required option 'http-server-port'!"
+ )
+ isTrue(
+ testwiseCoverageMode != ETestwiseCoverageMode.TEAMSCALE_UPLOAD
+ || teamscaleServer.isConfiguredForSingleProjectTeamscaleUpload,
+ "You use 'tia-mode=teamscale-upload' but did not set all required 'teamscale-' fields to facilitate" +
+ " a connection to Teamscale!"
+ )
+ isTrue(
+ testwiseCoverageMode != ETestwiseCoverageMode.TEAMSCALE_UPLOAD || teamscaleServer.hasCommitOrRevision(),
+ "You use 'tia-mode=teamscale-upload' but did not provide a revision or commit via the agent's '" + TeamscaleConfig.TEAMSCALE_REVISION_OPTION + "', '" +
+ TeamscaleConfig.TEAMSCALE_REVISION_MANIFEST_JAR_OPTION + "', '" + TeamscaleConfig.TEAMSCALE_COMMIT_OPTION +
+ "', '" + TeamscaleConfig.TEAMSCALE_COMMIT_MANIFEST_JAR_OPTION + "' or '" +
+ GIT_PROPERTIES_JAR_OPTION + "' option." +
+ " Auto-detecting the git.properties is currently not supported in this mode."
+ )
+ }
+
+ /**
+ * Creates a [TeamscaleClient] based on the agent options. Returns null if the user did not fully configure a
+ * Teamscale connection.
+ */
+ fun createTeamscaleClient(requireSingleProjectUploadConfig: Boolean): TeamscaleClient? {
+ if (teamscaleServer.isConfiguredForSingleProjectTeamscaleUpload ||
+ !requireSingleProjectUploadConfig && teamscaleServer.isConfiguredForServerConnection
+ ) {
+ return TeamscaleClient(
+ teamscaleServer.url.toString(), teamscaleServer.userName!!,
+ teamscaleServer.userAccessToken!!, teamscaleServer.project,
+ AgentUtils.USER_AGENT
+ )
+ }
+ return null
+ }
+
+ /** All available upload methods. */ /*package*/
+ enum class EUploadMethod {
+ /** Saving coverage files on disk. */
+ LOCAL_DISK,
+ /** Sending coverage to a single Teamscale project. */
+ TEAMSCALE_SINGLE_PROJECT,
+ /** Sending coverage to multiple Teamscale projects. */
+ TEAMSCALE_MULTI_PROJECT,
+ /** Sending coverage to multiple Teamscale projects based on SAP NWDI application definitions. */
+ SAP_NWDI_TEAMSCALE,
+ /** Sending coverage to an Artifactory. */
+ ARTIFACTORY,
+ /** Sending coverage to Azure file storage. */
+ AZURE_FILE_STORAGE,
+ }
+
+ /** Determines the upload method that should be used based on the set options. */ /*package*/
+ fun determineUploadMethod() = when {
+ artifactoryConfig.hasAllRequiredFieldsSet() -> EUploadMethod.ARTIFACTORY
+ azureFileStorageConfig.hasAllRequiredFieldsSet() -> EUploadMethod.AZURE_FILE_STORAGE
+ !sapNetWeaverJavaApplications.isEmpty() -> EUploadMethod.SAP_NWDI_TEAMSCALE
+ teamscaleServer.isConfiguredForMultiProjectUpload -> EUploadMethod.TEAMSCALE_MULTI_PROJECT
+ teamscaleServer.isConfiguredForSingleProjectTeamscaleUpload -> EUploadMethod.TEAMSCALE_SINGLE_PROJECT
+ else -> EUploadMethod.LOCAL_DISK
+ }
+
+ /** Creates an uploader for the coverage XMLs. */
+ @Throws(UploaderException::class)
+ fun createUploader(instrumentation: Instrumentation?) = when (determineUploadMethod()) {
+ EUploadMethod.TEAMSCALE_MULTI_PROJECT -> createTeamscaleMultiProjectUploader(instrumentation)
+ EUploadMethod.TEAMSCALE_SINGLE_PROJECT -> createTeamscaleSingleProjectUploader(instrumentation)
+ EUploadMethod.ARTIFACTORY -> createArtifactoryUploader(instrumentation)
+ EUploadMethod.AZURE_FILE_STORAGE -> AzureFileStorageUploader(
+ azureFileStorageConfig,
+ additionalMetaDataFiles
+ )
+ EUploadMethod.SAP_NWDI_TEAMSCALE -> {
+ logger.info("NWDI configuration detected. The Agent will try and auto-detect commit information by searching all profiled Jar/War/Ear/... files.")
+ createNwdiTeamscaleUploader(instrumentation)
+ }
+ EUploadMethod.LOCAL_DISK -> LocalDiskUploader()
+ }
+
+ @Throws(UploaderException::class)
+ private fun createArtifactoryUploader(instrumentation: Instrumentation?): IUploader {
+ gitPropertiesJar?.let { jar ->
+ logger.info(
+ "You did not provide a commit to upload to directly, so the Agent will try to" +
+ "auto-detect it by searching the provided " + GIT_PROPERTIES_JAR_OPTION + " at " +
+ jar.absolutePath + " for a git.properties file."
+ )
+ artifactoryConfig.commitInfo = ArtifactoryConfig.parseGitProperties(
+ jar, searchGitPropertiesRecursively, gitPropertiesCommitTimeFormat
+ )
+ }
+ if (!artifactoryConfig.hasCommitInfo()) {
+ logger.info(
+ "You did not provide a commit to upload to directly, so the Agent will try and" +
+ " auto-detect it by searching all profiled Jar/War/Ear/... files for a git.properties file."
+ )
+ return createDelayedArtifactoryUploader(instrumentation)
+ }
+ return ArtifactoryUploader(artifactoryConfig, additionalMetaDataFiles, reportFormat)
+ }
+
+ private fun createTeamscaleSingleProjectUploader(instrumentation: Instrumentation?): IUploader {
+ if (teamscaleServer.hasCommitOrRevision()) {
+ return TeamscaleUploader(teamscaleServer)
+ }
+
+ val uploader = createDelayedSingleProjectTeamscaleUploader()
+
+ gitPropertiesJar?.let { jar ->
+ logger.info(
+ "You did not provide a commit to upload to directly, so the Agent will try to" +
+ "auto-detect it by searching the provided " + GIT_PROPERTIES_JAR_OPTION + " at " +
+ jar.absolutePath + " for a git.properties file."
+ )
+ startGitPropertiesSearchInJarFile(uploader, jar)
+ return uploader
+ }
+
+ logger.info(
+ "You did not provide a commit to upload to directly, so the Agent will try and" +
+ " auto-detect it by searching all profiled Jar/War/Ear/... files for a git.properties file."
+ )
+ registerSingleGitPropertiesLocator(uploader, instrumentation)
+ return uploader
+ }
+
+ private fun createTeamscaleMultiProjectUploader(
+ instrumentation: Instrumentation?
+ ): DelayedTeamscaleMultiProjectUploader {
+ val uploader = DelayedTeamscaleMultiProjectUploader { project, commitInfo ->
+ if (commitInfo!!.preferCommitDescriptorOverRevision || isEmpty(commitInfo.revision)) {
+ return@DelayedTeamscaleMultiProjectUploader teamscaleServer.withProjectAndCommit(project!!, commitInfo.commit!!)
+ }
+ teamscaleServer.withProjectAndRevision(project!!, commitInfo.revision!!)
+ }
+
+ gitPropertiesJar?.let { jar ->
+ logger.info(
+ "You did not provide a Teamscale project to upload to directly, so the Agent will try and" +
+ " auto-detect it by searching the provided " + GIT_PROPERTIES_JAR_OPTION + " at " +
+ jar.absolutePath + " for a git.properties file."
+ )
+ startMultiGitPropertiesFileSearchInJarFile(uploader, jar)
+ return uploader
+ }
+ logger.info(
+ "You did not provide a Teamscale project to upload to directly, so the Agent will try and" +
+ " auto-detect it by searching all profiled Jar/War/Ear/... files for git.properties files" +
+ " with the 'teamscale.project' field set."
+ )
+ registerMultiGitPropertiesLocator(uploader, instrumentation)
+ return uploader
+ }
+
+ private fun startGitPropertiesSearchInJarFile(
+ uploader: DelayedUploader,
+ gitPropertiesJar: File
+ ) {
+ val locator = GitSingleProjectPropertiesLocator(
+ uploader,
+ { file, isJarFile, recursiveSearch, timeFormat ->
+ getProjectRevisionsFromGitProperties(file, isJarFile, recursiveSearch, timeFormat)
+ }, searchGitPropertiesRecursively,
+ gitPropertiesCommitTimeFormat
+ )
+ locator.searchFileForGitPropertiesAsync(gitPropertiesJar, true)
+ }
+
+ private fun registerSingleGitPropertiesLocator(
+ uploader: DelayedUploader,
+ instrumentation: Instrumentation?
+ ) {
+ val locator = GitSingleProjectPropertiesLocator(uploader,
+ { file, isJarFile, recursiveSearch, timeFormat ->
+ getProjectRevisionsFromGitProperties(file, isJarFile, recursiveSearch, timeFormat)
+ }, searchGitPropertiesRecursively,
+ gitPropertiesCommitTimeFormat
+ )
+ instrumentation?.addTransformer(GitPropertiesLocatingTransformer(locator, locationIncludeFilter))
+ }
+
+ private fun createDelayedSingleProjectTeamscaleUploader() = DelayedUploader(
+ { projectAndCommit: ProjectAndCommit ->
+ if (!isEmpty(projectAndCommit.project) && (teamscaleServer.project != projectAndCommit.project)) {
+ logger.warn(
+ "Teamscale project '" + teamscaleServer.project + "' specified in the agent configuration is not the same as the Teamscale project '" + projectAndCommit.project + "' specified in git.properties file(s). Proceeding to upload to the" +
+ " Teamscale project '" + teamscaleServer.project + "' specified in the agent configuration."
+ )
+ }
+ if (projectAndCommit.commitInfo!!.preferCommitDescriptorOverRevision ||
+ isEmpty(projectAndCommit.commitInfo.revision)
+ ) {
+ teamscaleServer.commit = projectAndCommit.commitInfo.commit
+ } else {
+ teamscaleServer.revision = projectAndCommit.commitInfo.revision
+ }
+ TeamscaleUploader(teamscaleServer)
+ }, outputDirectory!!
+ )
+
+ private fun startMultiGitPropertiesFileSearchInJarFile(
+ uploader: DelayedTeamscaleMultiProjectUploader,
+ gitPropertiesJar: File
+ ) {
+ GitMultiProjectPropertiesLocator(
+ uploader, searchGitPropertiesRecursively, gitPropertiesCommitTimeFormat
+ ).searchFileForGitPropertiesAsync(gitPropertiesJar, true)
+ }
+
+ private fun registerMultiGitPropertiesLocator(
+ uploader: DelayedTeamscaleMultiProjectUploader,
+ instrumentation: Instrumentation?
+ ) {
+ val locator = GitMultiProjectPropertiesLocator(
+ uploader, searchGitPropertiesRecursively, gitPropertiesCommitTimeFormat
+ )
+ instrumentation?.addTransformer(GitPropertiesLocatingTransformer(locator, locationIncludeFilter))
+ }
+
+ private fun createDelayedArtifactoryUploader(instrumentation: Instrumentation?): IUploader {
+ val uploader = DelayedUploader(
+ { commitInfo ->
+ artifactoryConfig.commitInfo = commitInfo
+ ArtifactoryUploader(artifactoryConfig, additionalMetaDataFiles, reportFormat)
+ }, outputDirectory!!
+ )
+ val locator = GitSingleProjectPropertiesLocator(
+ uploader,
+ { file, isJarFile, recursiveSearch, timeFormat ->
+ getCommitInfoFromGitProperties(
+ file, isJarFile, recursiveSearch, timeFormat
+ )
+ },
+ searchGitPropertiesRecursively, gitPropertiesCommitTimeFormat
+ )
+ instrumentation?.addTransformer(GitPropertiesLocatingTransformer(locator, locationIncludeFilter))
+ return uploader
+ }
+
+ private fun createNwdiTeamscaleUploader(instrumentation: Instrumentation?): IUploader {
+ val uploader = DelayedSapNwdiMultiUploader { commit, application ->
+ TeamscaleUploader(
+ teamscaleServer.withProjectAndCommit(application.teamscaleProject, commit)
+ )
+ }
+ instrumentation?.addTransformer(
+ NwdiMarkerClassLocatingTransformer(
+ uploader, locationIncludeFilter, sapNetWeaverJavaApplications
+ )
+ )
+ return uploader
+ }
+
+ private val reportFormat: EReportFormat
+ get() = if (useTestwiseCoverageMode()) {
+ EReportFormat.TESTWISE_COVERAGE
+ } else EReportFormat.JACOCO
+
+ /**
+ * Creates a new file with the given prefix, extension and current timestamp and ensures that the parent folder
+ * actually exists.
+ */
+ @Throws(IOException::class)
+ fun createNewFileInOutputDirectory(prefix: String, extension: String): File {
+ ensureDirectoryExists(outputDirectory!!.toFile())
+ return outputDirectory!!.resolve(
+ "$prefix-${LocalDateTime.now().format(DATE_TIME_FORMATTER)}.$extension"
+ ).toFile()
+ }
+
+ /**
+ * Creates a new file with the given prefix, extension and current timestamp and ensures that the parent folder
+ * actually exists. One output folder is created per partition.
+ */
+ @Throws(IOException::class)
+ fun createNewFileInPartitionOutputDirectory(prefix: String, extension: String): File {
+ val partitionOutputDir = outputDirectory!!.resolve(
+ safeFolderName(teamscaleServer.partition!!)
+ )
+ ensureDirectoryExists(partitionOutputDir.toFile())
+ return partitionOutputDir.resolve(
+ "$prefix-${LocalDateTime.now().format(DATE_TIME_FORMATTER)}.$extension"
+ ).toFile()
+ }
+
+ /**
+ * Sets the parent of the output directory for this run. The output directory itself will be created in this folder
+ * is named after the current timestamp with the format yyyy-MM-dd-HH-mm-ss.SSS
+ */
+ fun setParentOutputDirectory(outputDirectoryParent: Path) {
+ outputDirectory = outputDirectoryParent.resolve(LocalDateTime.now().format(DATE_TIME_FORMATTER))
+ }
+
+ /** Returns whether the config indicates to use Test Impact mode. */
+ fun useTestwiseCoverageMode() = mode == EMode.TESTWISE
+
+ val locationIncludeFilter: ClasspathWildcardIncludeFilter
+ get() = ClasspathWildcardIncludeFilter(jacocoIncludes, jacocoExcludes)
+
+ /** Whether coverage should be dumped in regular intervals. */
+ fun shouldDumpInIntervals() = dumpIntervalInMinutes > 0
+
+ /** @return the [TeamscaleProxyOptions] for the given protocol. */
+ fun getTeamscaleProxyOptions(protocol: ProxySystemProperties.Protocol?) =
+ if (protocol == ProxySystemProperties.Protocol.HTTP) {
+ teamscaleProxyOptionsForHttp
+ } else teamscaleProxyOptionsForHttps
+
+ companion object {
+ /**
+ * Can be used to format [java.time.LocalDate] to the format "yyyy-MM-dd-HH-mm-ss.SSS"
+ */
+ val DATE_TIME_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd-HH-mm-ss.SSS", Locale.ENGLISH)
+
+ /**
+ * The default excludes applied to JaCoCo. These are packages that should never be profiled. Excluding them makes
+ * debugging traces easier and reduces trace size and warnings about unmatched classes in Teamscale.
+ */
+ const val DEFAULT_EXCLUDES =
+ "shadow.*:com.sun.*:sun.*:org.eclipse.*:org.junit.*:junit.*:org.apache.*:org.slf4j.*:javax.*:org.gradle.*:java.*:org.jboss.*:org.wildfly.*:org.springframework.*:com.fasterxml.*:jakarta.*:org.aspectj.*:org.h2.*:org.hibernate.*:org.assertj.*:org.mockito.*:org.thymeleaf.*"
+
+ /** Option name that allows to specify a jar file that contains the git commit hash in a git.properties file. */
+ const val GIT_PROPERTIES_JAR_OPTION = "git-properties-jar"
+
+ /**
+ * Specifies the date format in which the commit timestamp in the git.properties file is formatted.
+ */
+ const val GIT_PROPERTIES_COMMIT_DATE_FORMAT_OPTION = "git-properties-commit-date-format"
+
+ private fun safeFolderName(folderName: String): Path {
+ val result = folderName.replace("[<>:\"/|?*]".toRegex(), "")
+ .replace("\\.+".toRegex(), "dot")
+ .replace("\\x00".toRegex(), "")
+ .replace("[. ]$".toRegex(), "")
+
+ return if (result.isEmpty()) {
+ Paths.get("default")
+ } else {
+ Paths.get(result)
+ }
+ }
+ }
+}
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/AgentOptionsParser.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/AgentOptionsParser.kt
new file mode 100644
index 000000000..4801977f4
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/AgentOptionsParser.kt
@@ -0,0 +1,535 @@
+package com.teamscale.jacoco.agent.options
+
+import com.teamscale.client.FileSystemUtils.readFileUTF8
+import com.teamscale.client.HttpUtils.setShouldValidateSsl
+import com.teamscale.client.ProxySystemProperties
+import com.teamscale.client.StringUtils.isEmpty
+import com.teamscale.client.StringUtils.splitLinesAsList
+import com.teamscale.client.StringUtils.stripPrefix
+import com.teamscale.jacoco.agent.configuration.AgentOptionReceiveException
+import com.teamscale.jacoco.agent.configuration.ConfigurationViaTeamscale
+import com.teamscale.jacoco.agent.options.sapnwdi.SapNwdiApplication.Companion.parseApplications
+import com.teamscale.jacoco.agent.upload.artifactory.ArtifactoryConfig
+import com.teamscale.jacoco.agent.upload.artifactory.ArtifactoryConfig.Companion.handleArtifactoryOptions
+import com.teamscale.jacoco.agent.upload.azure.AzureFileStorageConfig.Companion.handleAzureFileStorageOptions
+import com.teamscale.jacoco.agent.upload.teamscale.TeamscaleConfig
+import com.teamscale.report.util.ILogger
+import okhttp3.HttpUrl
+import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
+import org.jetbrains.annotations.VisibleForTesting
+import java.io.File
+import java.io.FileNotFoundException
+import java.io.IOException
+import java.nio.file.InvalidPathException
+import java.nio.file.Path
+import java.nio.file.Paths
+import java.time.format.DateTimeFormatter
+import java.util.*
+
+/**
+ * Parses agent command line options.
+ */
+class AgentOptionsParser @VisibleForTesting internal constructor(
+ private val logger: ILogger,
+ private val environmentConfigId: String?,
+ private val environmentConfigFile: String?,
+ private val credentials: TeamscaleCredentials?,
+ private val environmentAccessToken: String?
+) {
+ private val filePatternResolver = FilePatternResolver(logger)
+ private val teamscaleConfig = TeamscaleConfig(logger, filePatternResolver)
+ private val collectedErrors = mutableListOf()
+
+ /**
+ * Throw the first collected exception, if present.
+ */
+ @VisibleForTesting
+ @Throws(Exception::class)
+ fun throwOnCollectedErrors() {
+ collectedErrors.forEach { throw it }
+ }
+
+ /**
+ * Parses the given command-line options.
+ */
+ /* package */
+ @Throws(AgentOptionParseException::class, AgentOptionReceiveException::class)
+ fun parse(optionsString: String?): AgentOptions {
+ var optionsString = optionsString
+ if (optionsString == null) {
+ optionsString = ""
+ }
+ logger.debug("Parsing options: $optionsString")
+
+ val options = AgentOptions(logger)
+ options.originalOptionsString = optionsString
+
+ presetCredentialOptions(options)
+
+ if (!isEmpty(optionsString)) {
+ val optionParts = optionsString.split(",".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
+ optionParts.forEach { optionPart ->
+ try {
+ handleOptionPart(options, optionPart)
+ } catch (e: Exception) {
+ collectedErrors.add(e)
+ }
+ }
+ }
+
+ // we have to put the proxy options into system properties before reading the configuration from Teamscale as we
+ // might need them to connect to Teamscale
+ putTeamscaleProxyOptionsIntoSystemProperties(options)
+
+ handleConfigId(options)
+ handleConfigFile(options)
+
+ val validator = options.validator
+ if (!validator.isValid) {
+ collectedErrors.add(AgentOptionParseException("Invalid options given: ${validator.errorMessage}"))
+ }
+
+ return options
+ }
+
+ private fun presetCredentialOptions(options: AgentOptions) {
+ if (credentials != null) {
+ options.teamscaleServer.url = credentials.url
+ options.teamscaleServer.userName = credentials.userName
+ options.teamscaleServer.userAccessToken = credentials.accessKey
+ }
+ if (environmentAccessToken != null) {
+ options.teamscaleServer.userAccessToken = environmentAccessToken
+ }
+ }
+
+ @Throws(AgentOptionReceiveException::class, AgentOptionParseException::class)
+ private fun handleConfigId(options: AgentOptions) {
+ if (environmentConfigId != null) {
+ if (options.teamscaleServer.configId != null) {
+ logger.warn(
+ "You specified an ID for a profiler configuration in Teamscale both in the agent options and using an environment variable." +
+ " The environment variable will override the ID specified using the agent options." +
+ " Please use one or the other."
+ )
+ }
+ handleOptionPart(options, "config-id=$environmentConfigId")
+ }
+
+ readConfigFromTeamscale(options)
+ }
+
+ @Throws(AgentOptionParseException::class)
+ private fun handleConfigFile(options: AgentOptions) {
+ if (environmentConfigFile != null) {
+ handleOptionPart(options, "config-file=$environmentConfigFile")
+ }
+
+ if (environmentConfigId != null && environmentConfigFile != null) {
+ logger.warn(
+ "You specified both an ID for a profiler configuration in Teamscale and a config file." +
+ " The config file will override the Teamscale configuration." +
+ " Please use one or the other."
+ )
+ }
+ }
+
+ /**
+ * Parses and stores the given option in the format `key=value`.
+ */
+ @Throws(AgentOptionParseException::class)
+ private fun handleOptionPart(
+ options: AgentOptions,
+ optionPart: String
+ ) {
+ val (key, value) = parseOption(optionPart)
+ handleOption(options, key, value)
+ }
+
+ /**
+ * Parses and stores the option with the given key and value.
+ */
+ @Throws(AgentOptionParseException::class)
+ private fun handleOption(
+ options: AgentOptions,
+ key: String, value: String
+ ) {
+ if (key.startsWith(DEBUG)) {
+ handleDebugOption(options, value)
+ return
+ }
+ if (key.startsWith("jacoco-")) {
+ options.additionalJacocoOptions.add(key.substring(7) to value)
+ return
+ }
+ if (key.startsWith("teamscale-") && teamscaleConfig.handleTeamscaleOptions(options.teamscaleServer, key, value)) return
+ if (key.startsWith("artifactory-") && handleArtifactoryOptions(options.artifactoryConfig, key, value)) return
+ if (key.startsWith("azure-") && handleAzureFileStorageOptions(options.azureFileStorageConfig, key, value)) return
+ if (key.startsWith("proxy-") && handleProxyOptions(options, stripPrefix(key, "proxy-"), value)) return
+ if (handleAgentOptions(options, key, value)) return
+ throw AgentOptionParseException("Unknown option: $key")
+ }
+
+ @Throws(AgentOptionParseException::class)
+ private fun handleProxyOptions(
+ options: AgentOptions, key: String, value: String
+ ): Boolean {
+ val httpsPrefix = "${ProxySystemProperties.Protocol.HTTPS}-"
+ if (key.startsWith(httpsPrefix)
+ && options.getTeamscaleProxyOptions(ProxySystemProperties.Protocol.HTTPS)!!
+ .handleTeamscaleProxyOptions(stripPrefix(key, httpsPrefix), value)
+ ) return true
+
+ val httpPrefix = ProxySystemProperties.Protocol.HTTP.toString() + "-"
+ if (key.startsWith(httpPrefix)
+ && options.getTeamscaleProxyOptions(ProxySystemProperties.Protocol.HTTP)!!
+ .handleTeamscaleProxyOptions(stripPrefix(key, httpPrefix), value)
+ ) return true
+
+ if (key == "password-file") {
+ val proxyPasswordPath = parsePath(filePatternResolver, key, value)
+ options.getTeamscaleProxyOptions(ProxySystemProperties.Protocol.HTTPS)!!
+ .proxyPasswordPath = proxyPasswordPath
+ options.getTeamscaleProxyOptions(ProxySystemProperties.Protocol.HTTP)!!
+ .proxyPasswordPath = proxyPasswordPath
+ return true
+ }
+ return false
+ }
+
+ /** Parses and stores the debug logging file path if given. */
+ private fun handleDebugOption(options: AgentOptions, value: String) {
+ if (value.equals("false", ignoreCase = true)) return
+ options.isDebugLogging = true
+ if (!value.isEmpty() && !value.equals("true", ignoreCase = true)) {
+ options.debugLogDirectory = Paths.get(value)
+ }
+ }
+
+ @Throws(AgentOptionParseException::class)
+ private fun parseOption(optionPart: String): Pair {
+ val keyAndValue = optionPart.split("=".toRegex(), limit = 2).toTypedArray()
+ if (keyAndValue.size < 2) {
+ throw AgentOptionParseException("Got an option without any value: $optionPart")
+ }
+
+ val key = keyAndValue[0].lowercase(Locale.getDefault())
+ var value = keyAndValue[1]
+
+ // Remove quotes, which may be used to pass arguments with spaces via
+ // the command line
+ if (value.startsWith("\"") && value.endsWith("\"")) {
+ value = value.substring(1, value.length - 1)
+ }
+ return key to value
+ }
+
+ /**
+ * Handles all common command line options for the agent.
+ *
+ * @return true if it has successfully processed the given option.
+ */
+ @Throws(AgentOptionParseException::class)
+ private fun handleAgentOptions(options: AgentOptions, key: String, value: String): Boolean {
+ when (key) {
+ "config-id" -> {
+ options.teamscaleServer.configId = value
+ return true
+ }
+ CONFIG_FILE_OPTION -> {
+ readConfigFromFile(options, parsePath(filePatternResolver, key, value)!!.toFile())
+ return true
+ }
+ LOGGING_CONFIG_OPTION -> {
+ options.loggingConfig = parsePath(filePatternResolver, key, value)
+ return true
+ }
+ "interval" -> {
+ options.dumpIntervalInMinutes = parseInt(key, value)
+ return true
+ }
+ "validate-ssl" -> {
+ options.validateSsl = value.toBoolean()
+ return true
+ }
+ "out" -> {
+ options.setParentOutputDirectory(parsePath(filePatternResolver, key, value)!!)
+ return true
+ }
+ "upload-metadata" -> {
+ try {
+ options.additionalMetaDataFiles = splitMultiOptionValue(value).map { path -> Paths.get(path) }
+ } catch (e: InvalidPathException) {
+ throw AgentOptionParseException("Invalid path given for option 'upload-metadata'", e)
+ }
+ return true
+ }
+ "duplicates" -> {
+ options.duplicateClassFileBehavior = parseEnumValue(key, value)
+ return true
+ }
+ "ignore-uncovered-classes" -> {
+ options.ignoreUncoveredClasses = value.toBoolean()
+ return true
+ }
+ "obfuscate-security-related-outputs" -> {
+ options.obfuscateSecurityRelatedOutputs = value.toBoolean()
+ return true
+ }
+ "dump-on-exit" -> {
+ options.shouldDumpOnExit = value.toBoolean()
+ return true
+ }
+ "search-git-properties-recursively" -> {
+ options.searchGitPropertiesRecursively = value.toBoolean()
+ return true
+ }
+ ArtifactoryConfig.ARTIFACTORY_GIT_PROPERTIES_JAR_OPTION -> {
+ logger.warn(
+ "The option " + ArtifactoryConfig.ARTIFACTORY_GIT_PROPERTIES_JAR_OPTION + " is deprecated. It still has an effect, " +
+ "but should be replaced with the equivalent option " + AgentOptions.GIT_PROPERTIES_JAR_OPTION + "."
+ )
+ options.gitPropertiesJar = getGitPropertiesJarFile(value)
+ return true
+ }
+ AgentOptions.GIT_PROPERTIES_JAR_OPTION -> {
+ options.gitPropertiesJar = getGitPropertiesJarFile(value)
+ return true
+ }
+ ArtifactoryConfig.ARTIFACTORY_GIT_PROPERTIES_COMMIT_DATE_FORMAT_OPTION -> {
+ logger.warn(
+ "The option " + ArtifactoryConfig.ARTIFACTORY_GIT_PROPERTIES_COMMIT_DATE_FORMAT_OPTION + " is deprecated. It still has an effect, " +
+ "but should be replaced with the equivalent option " + AgentOptions.GIT_PROPERTIES_COMMIT_DATE_FORMAT_OPTION + "."
+ )
+ options.gitPropertiesCommitTimeFormat = DateTimeFormatter.ofPattern(value)
+ return true
+ }
+ AgentOptions.GIT_PROPERTIES_COMMIT_DATE_FORMAT_OPTION -> {
+ options.gitPropertiesCommitTimeFormat = DateTimeFormatter.ofPattern(value)
+ return true
+ }
+ "mode" -> {
+ options.mode = parseEnumValue(key, value)
+ return true
+ }
+ "includes" -> {
+ options.jacocoIncludes = value.replace(";".toRegex(), ":")
+ return true
+ }
+ "excludes" -> {
+ options.jacocoExcludes = value.replace(";".toRegex(), ":") + ":" + AgentOptions.DEFAULT_EXCLUDES
+ return true
+ }
+ "class-dir" -> {
+ val list = splitMultiOptionValue(value)
+ try {
+ options.classDirectoriesOrZips = ClasspathUtils.resolveClasspathTextFiles(
+ key, filePatternResolver, list
+ )
+ } catch (e: IOException) {
+ throw AgentOptionParseException(e)
+ }
+ return true
+ }
+ "http-server-port" -> {
+ options.httpServerPort = parseInt(key, value)
+ return true
+ }
+ "sap-nwdi-applications" -> {
+ options.sapNetWeaverJavaApplications = parseApplications(value)
+ return true
+ }
+ "tia-mode" -> {
+ options.testwiseCoverageMode = parseEnumValue(key, value)
+ return true
+ }
+ else -> return false
+ }
+ }
+
+ @Throws(AgentOptionParseException::class, AgentOptionReceiveException::class)
+ private fun readConfigFromTeamscale(options: AgentOptions) {
+ if (options.teamscaleServer.configId == null) return
+ if (!options.teamscaleServer.isConfiguredForServerConnection) {
+ throw AgentOptionParseException(
+ "Config-id '${options.teamscaleServer.configId}' specified without teamscale url/user/accessKey! These options must be provided locally via config-file or command line argument."
+ )
+ }
+ // Set ssl validation option in case it needs to be off before trying to reach Teamscale.
+ setShouldValidateSsl(options.validateSsl)
+ val configuration = ConfigurationViaTeamscale.retrieve(
+ logger,
+ options.teamscaleServer.configId,
+ options.teamscaleServer.url!!,
+ options.teamscaleServer.userName!!,
+ options.teamscaleServer.userAccessToken!!
+ )
+ options.configurationViaTeamscale = configuration
+ logger.debug("Received the following options from Teamscale: ${configuration.profilerConfiguration!!.configurationOptions}")
+ readConfigFromString(options, configuration.profilerConfiguration!!.configurationOptions)
+ }
+
+ private fun getGitPropertiesJarFile(path: String): File? {
+ val jarFile = File(path)
+ if (!jarFile.exists()) {
+ logger.warn("The path provided with the ${AgentOptions.GIT_PROPERTIES_JAR_OPTION} option does not exist: $path. Continuing without searching it for git.properties files.")
+ return null
+ }
+ if (!jarFile.isFile()) {
+ logger.warn("The path provided with the ${AgentOptions.GIT_PROPERTIES_JAR_OPTION} option is not a regular file (probably a folder instead): $path. Continuing without searching it for git.properties files.")
+ return null
+ }
+ return jarFile
+ }
+
+ /**
+ * Reads configuration parameters from the given file. The expected format is basically the same as for the command
+ * line, but line breaks are also considered as separators. e.g. class-dir=out # Some comment includes=test.*
+ * excludes=third.party.*
+ */
+ @Throws(AgentOptionParseException::class)
+ private fun readConfigFromFile(
+ options: AgentOptions, configFile: File
+ ) {
+ try {
+ val content = readFileUTF8(configFile)
+ readConfigFromString(options, content)
+ } catch (e: FileNotFoundException) {
+ throw AgentOptionParseException("File ${configFile.absolutePath} given for option 'config-file' not found", e)
+ } catch (e: IOException) {
+ throw AgentOptionParseException("An error occurred while reading the config file ${configFile.absolutePath}", e)
+ }
+ }
+
+ private fun readConfigFromString(options: AgentOptions, content: String?) {
+ splitLinesAsList(content).forEach { optionKeyValue ->
+ try {
+ val trimmedOption = optionKeyValue.trim { it <= ' ' }
+ if (trimmedOption.isEmpty() || trimmedOption.startsWith(COMMENT_PREFIX)) {
+ return@forEach
+ }
+ handleOptionPart(options, optionKeyValue)
+ } catch (e: Exception) {
+ collectedErrors.add(e)
+ }
+ }
+ }
+
+ @Throws(AgentOptionParseException::class)
+ private fun parseInt(key: String?, value: String): Int {
+ try {
+ return value.toInt()
+ } catch (_: NumberFormatException) {
+ throw AgentOptionParseException("Invalid non-numeric value for option `${key}`: $value")
+ }
+ }
+
+ companion object {
+ /** The name of the option for providing the logging config. */
+ const val LOGGING_CONFIG_OPTION: String = "logging-config"
+
+ /** The name of the option for providing the config file. */
+ const val CONFIG_FILE_OPTION: String = "config-file"
+
+ /** Character which starts a comment in the config file. */
+ private const val COMMENT_PREFIX = "#"
+
+ /** The name of the option that enables debug logging. */
+ const val DEBUG: String = "debug"
+
+ /**
+ * Parses the given command-line options.
+ *
+ * @param environmentConfigId The Profiler configuration ID given via an environment variable.
+ * @param environmentConfigFile The Profiler configuration file given via an environment variable.
+ * @param credentials Optional Teamscale credentials from a teamscale.properties file.
+ * @param environmentAccessToken Optional access token for accessing Teamscale, read from an env variable.
+ */
+ @Throws(AgentOptionParseException::class, AgentOptionReceiveException::class)
+ fun parse(
+ optionsString: String,
+ environmentConfigId: String?,
+ environmentConfigFile: String?, credentials: TeamscaleCredentials?,
+ environmentAccessToken: String?,
+ logger: ILogger
+ ): Pair> {
+ val parser = AgentOptionsParser(
+ logger, environmentConfigId, environmentConfigFile,
+ credentials, environmentAccessToken
+ )
+ val options = parser.parse(optionsString)
+ return options to parser.collectedErrors
+ }
+
+ /**
+ * Stores the agent options for proxies in the [com.teamscale.client.TeamscaleProxySystemProperties] and overwrites the password
+ * with the password found in the proxy-password-file if necessary.
+ */
+ @JvmStatic
+ @VisibleForTesting
+ fun putTeamscaleProxyOptionsIntoSystemProperties(options: AgentOptions) {
+ options.getTeamscaleProxyOptions(ProxySystemProperties.Protocol.HTTP)
+ ?.putTeamscaleProxyOptionsIntoSystemProperties()
+ options.getTeamscaleProxyOptions(ProxySystemProperties.Protocol.HTTPS)
+ ?.putTeamscaleProxyOptionsIntoSystemProperties()
+ }
+
+ /**
+ * Interprets the given pattern as an Ant pattern and resolves it to one existing [Path]. If the given path is
+ * relative, it is resolved relative to the current working directory. If more than one file matches the pattern,
+ * one of the matching files is used without any guarantees as to which. The selection is, however, guaranteed to be
+ * deterministic, i.e. if you run the pattern twice and get the same set of files, the same file will be picked each
+ * time.
+ */
+ @Throws(AgentOptionParseException::class)
+ fun parsePath(
+ filePatternResolver: FilePatternResolver,
+ optionName: String?,
+ pattern: String?
+ ): Path? {
+ try {
+ return filePatternResolver.parsePath(optionName, pattern)
+ } catch (e: IOException) {
+ throw AgentOptionParseException(e)
+ }
+ }
+
+ /**
+ * Parses the given value as a URL.
+ */
+ @Throws(AgentOptionParseException::class)
+ fun parseUrl(key: String?, value: String): HttpUrl {
+ var value = value
+ if (!value.endsWith("/")) {
+ value += "/"
+ }
+
+ // default to HTTP if no scheme is given and port is not 443, default to HTTPS if no scheme is given AND port is 443
+ if (!value.startsWith("http://") && !value.startsWith("https://")) {
+ val url: HttpUrl = getUrl(key, "http://$value")
+ value = if (url.port == 443) {
+ "https://$value"
+ } else {
+ "http://$value"
+ }
+ }
+
+ return getUrl(key, value)
+ }
+
+ @Throws(AgentOptionParseException::class)
+ private fun getUrl(key: String?, value: String) = value.toHttpUrlOrNull() ?: throw AgentOptionParseException("Invalid URL given for option '$key'")
+
+ /**
+ * Splits the given value at semicolons.
+ */
+ private fun splitMultiOptionValue(value: String) = value.split(";".toRegex()).dropLastWhile { it.isEmpty() }
+
+ inline fun > parseEnumValue(key: String, value: String) = try {
+ enumValueOf(value.uppercase().replace("-", "_"))
+ } catch (e: IllegalArgumentException) {
+ val validValues = enumValues().joinToString(", ") { it.name }
+ throw AgentOptionParseException("Invalid value for option `$key`. Valid values: $validValues", e)
+ }
+ }
+}
diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/options/EMode.java b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/EMode.kt
similarity index 86%
rename from agent/src/main/java/com/teamscale/jacoco/agent/options/EMode.java
rename to agent/src/main/kotlin/com/teamscale/jacoco/agent/options/EMode.kt
index dcfe86b87..7bb684400 100644
--- a/agent/src/main/java/com/teamscale/jacoco/agent/options/EMode.java
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/EMode.kt
@@ -1,14 +1,12 @@
-package com.teamscale.jacoco.agent.options;
-
-/** Describes the two possible modes the agent can be started in. */
-public enum EMode {
+package com.teamscale.jacoco.agent.options
+/** Describes the two possible modes the agent can be started in. */
+enum class EMode {
/**
* The default mode which produces JaCoCo XML coverage files on exit, in a defined interval or when triggered via an
* HTTP endpoint. Each dump produces a new file containing the all collected coverage.
*/
NORMAL,
-
/**
* Testwise coverage mode in which the agent only dumps when triggered via an HTTP endpoint. Coverage is written as
* exec and appended into a single file.
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/ETestwiseCoverageMode.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/ETestwiseCoverageMode.kt
new file mode 100644
index 000000000..805a00238
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/ETestwiseCoverageMode.kt
@@ -0,0 +1,13 @@
+package com.teamscale.jacoco.agent.options
+
+/** Decides which [com.teamscale.jacoco.agent.testimpact.TestEventHandlerStrategyBase] is used in testwise mode. */
+enum class ETestwiseCoverageMode {
+ /** Caches testwise coverage in-memory and uploads a report to Teamscale. */
+ TEAMSCALE_UPLOAD,
+ /** Writes testwise coverage to disk as .json files. */
+ DISK,
+ /** Writes testwise coverage to disk as .exec files. */
+ EXEC_FILE,
+ /** Returns testwise coverage to the caller via HTTP. */
+ HTTP
+}
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/JacocoAgentOptionsBuilder.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/JacocoAgentOptionsBuilder.kt
new file mode 100644
index 000000000..c6965fd7e
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/JacocoAgentOptionsBuilder.kt
@@ -0,0 +1,88 @@
+package com.teamscale.jacoco.agent.options
+
+import com.teamscale.jacoco.agent.logging.LoggingUtils.getLogger
+import com.teamscale.jacoco.agent.util.AgentUtils.agentDirectory
+import com.teamscale.jacoco.agent.util.AgentUtils.mainTempDirectory
+import java.io.File
+import java.io.IOException
+import java.nio.file.Files
+import java.nio.file.Path
+import java.util.function.Consumer
+
+/** Builder for the JaCoCo agent options string. */
+class JacocoAgentOptionsBuilder(private val agentOptions: AgentOptions) {
+ private val logger = getLogger(this)
+
+ /**
+ * Returns the options to pass to the JaCoCo agent.
+ */
+ @Throws(AgentOptionParseException::class, IOException::class)
+ fun createJacocoAgentOptions(): String {
+ val builder = StringBuilder(modeSpecificOptions)
+ if (agentOptions.jacocoIncludes != null) {
+ builder.append(",includes=").append(agentOptions.jacocoIncludes)
+ }
+
+ logger.debug("Using default excludes: ${AgentOptions.DEFAULT_EXCLUDES}")
+ builder.append(",excludes=").append(agentOptions.jacocoExcludes)
+
+ // Don't dump class files in testwise mode when coverage is written to an exec file
+ val needsClassFiles =
+ agentOptions.mode == EMode.NORMAL || agentOptions.testwiseCoverageMode != ETestwiseCoverageMode.EXEC_FILE
+ if (agentOptions.classDirectoriesOrZips.isEmpty() && needsClassFiles) {
+ val tempDir = createTemporaryDumpDirectory()
+ tempDir.toFile().deleteOnExit()
+ builder.append(",classdumpdir=").append(tempDir.toAbsolutePath())
+
+ agentOptions.classDirectoriesOrZips = mutableListOf(tempDir.toFile())
+ }
+
+ agentOptions.additionalJacocoOptions.forEach { pair ->
+ builder.append(",").append(pair.first).append("=").append(pair.second)
+ }
+
+ return builder.toString()
+ }
+
+ @Throws(AgentOptionParseException::class)
+ private fun createTemporaryDumpDirectory(): Path {
+ try {
+ return Files.createDirectory(mainTempDirectory.resolve("jacoco-class-dump"))
+ } catch (_: IOException) {
+ logger.warn("Unable to create temporary directory in default location. Trying in system temp directory.")
+ }
+
+ try {
+ return Files.createTempDirectory("jacoco-class-dump")
+ } catch (_: IOException) {
+ logger.warn("Unable to create temporary directory in default location. Trying in output directory.")
+ }
+
+ try {
+ return Files.createTempDirectory(agentOptions.outputDirectory, "jacoco-class-dump")
+ } catch (_: IOException) {
+ logger.warn("Unable to create temporary directory in output directory. Trying in agent's directory.")
+ }
+
+ val agentDirectory = agentDirectory
+ try {
+ return Files.createTempDirectory(agentDirectory, "jacoco-class-dump")
+ } catch (e: IOException) {
+ throw AgentOptionParseException("Unable to create a temporary directory anywhere", e)
+ }
+ }
+
+ /**
+ * Returns additional options for JaCoCo depending on the selected [AgentOptions.mode] and
+ * [AgentOptions.testwiseCoverageMode].
+ */
+ @get:Throws(IOException::class)
+ val modeSpecificOptions: String
+ get() = if (agentOptions.useTestwiseCoverageMode() && agentOptions.testwiseCoverageMode == ETestwiseCoverageMode.EXEC_FILE) {
+ // when writing to a .exec file, we can instruct JaCoCo to do so directly
+ "destfile=${agentOptions.createNewFileInOutputDirectory("jacoco", "exec").absolutePath}"
+ } else {
+ // otherwise we don't need JaCoCo to perform any output of the .exec information
+ "output=none"
+ }
+}
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/ProjectAndCommit.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/ProjectAndCommit.kt
new file mode 100644
index 000000000..260eb3a7c
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/ProjectAndCommit.kt
@@ -0,0 +1,10 @@
+package com.teamscale.jacoco.agent.options
+
+import com.teamscale.jacoco.agent.commit_resolution.git_properties.CommitInfo
+import java.util.*
+
+/** Class encapsulating the Teamscale project and git commitInfo an upload should be performed to. */
+data class ProjectAndCommit(
+ @JvmField val project: String?,
+ @JvmField val commitInfo: CommitInfo?
+)
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/TeamscaleCredentials.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/TeamscaleCredentials.kt
new file mode 100644
index 000000000..7405c4ae3
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/TeamscaleCredentials.kt
@@ -0,0 +1,13 @@
+package com.teamscale.jacoco.agent.options
+
+import okhttp3.HttpUrl
+
+/** Credentials for accessing a Teamscale instance. */
+class TeamscaleCredentials(
+ /** The URL of the Teamscale server. */
+ @JvmField val url: HttpUrl?,
+ /** The user name used to authenticate against Teamscale. */
+ @JvmField val userName: String?,
+ /** The user's access key. */
+ @JvmField val accessKey: String?
+)
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/TeamscalePropertiesUtils.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/TeamscalePropertiesUtils.kt
new file mode 100644
index 000000000..3b05c3c06
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/TeamscalePropertiesUtils.kt
@@ -0,0 +1,71 @@
+package com.teamscale.jacoco.agent.options
+
+import com.teamscale.client.FileSystemUtils.readProperties
+import com.teamscale.jacoco.agent.util.AgentUtils.agentDirectory
+import okhttp3.HttpUrl
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import java.io.IOException
+import java.nio.file.Files
+import java.nio.file.Path
+import java.util.*
+import kotlin.io.path.exists
+
+/**
+ * Utilities for working with the teamscale.properties file that contains access credentials for the Teamscale
+ * instance.
+ */
+object TeamscalePropertiesUtils {
+ private val TEAMSCALE_PROPERTIES_PATH = agentDirectory.resolve("teamscale.properties")
+
+ /**
+ * Tries to open [.TEAMSCALE_PROPERTIES_PATH] and parse that properties file to obtain
+ * [TeamscaleCredentials].
+ *
+ * @return the parsed credentials or null in case the teamscale.properties file doesn't exist.
+ * @throws AgentOptionParseException in case the teamscale.properties file exists but can't be read or parsed.
+ */
+ @Throws(AgentOptionParseException::class)
+ fun parseCredentials() = parseCredentials(TEAMSCALE_PROPERTIES_PATH)
+
+ /**
+ * Same as [.parseCredentials] but testable since the path is not hardcoded.
+ */
+ /*package*/
+ @JvmStatic
+ @Throws(AgentOptionParseException::class)
+ fun parseCredentials(
+ teamscalePropertiesPath: Path
+ ): TeamscaleCredentials? {
+ if (!teamscalePropertiesPath.exists()) {
+ return null
+ }
+
+ try {
+ val properties = readProperties(teamscalePropertiesPath.toFile())
+ return parseProperties(properties)
+ } catch (e: IOException) {
+ throw AgentOptionParseException("Failed to read $teamscalePropertiesPath", e)
+ }
+ }
+
+ @Throws(AgentOptionParseException::class)
+ private fun parseProperties(properties: Properties): TeamscaleCredentials {
+ val urlString = properties.getProperty("url")
+ ?: throw AgentOptionParseException("teamscale.properties is missing the url field")
+
+ val url: HttpUrl
+ try {
+ url = urlString.toHttpUrl()
+ } catch (e: IllegalArgumentException) {
+ throw AgentOptionParseException("teamscale.properties contained malformed URL $urlString", e)
+ }
+
+ val userName = properties.getProperty("username")
+ ?: throw AgentOptionParseException("teamscale.properties is missing the username field")
+
+ val accessKey = properties.getProperty("accesskey")
+ ?: throw AgentOptionParseException("teamscale.properties is missing the accesskey field")
+
+ return TeamscaleCredentials(url, userName, accessKey)
+ }
+}
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/TeamscaleProxyOptions.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/TeamscaleProxyOptions.kt
new file mode 100644
index 000000000..4aa6febc6
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/TeamscaleProxyOptions.kt
@@ -0,0 +1,117 @@
+package com.teamscale.jacoco.agent.options
+
+import com.teamscale.client.FileSystemUtils.readFileUTF8
+import com.teamscale.client.ProxySystemProperties
+import com.teamscale.client.StringUtils.isEmpty
+import com.teamscale.client.TeamscaleProxySystemProperties
+import com.teamscale.report.util.ILogger
+import java.io.IOException
+import java.nio.file.Path
+
+/**
+ * Parses agent command line options related to the proxy settings.
+ */
+class TeamscaleProxyOptions(private val protocol: ProxySystemProperties.Protocol, private val logger: ILogger) {
+ /** The host of the proxy server. */ /* package */
+ @JvmField
+ var proxyHost: String?
+
+ /** The port of the proxy server. */ /* package */
+ @JvmField
+ var proxyPort: Int = 0
+
+ /** The password for the proxy user. */ /* package */
+ @JvmField
+ var proxyPassword: String?
+
+ /** A path to the file that contains the password for the proxy authentication. */ /* package */
+ var proxyPasswordPath: Path? = null
+
+ /** The username of the proxy user. */ /* package */
+ @JvmField
+ var proxyUser: String?
+
+ /** Constructor. */
+ init {
+ val proxySystemProperties = ProxySystemProperties(protocol)
+ proxyHost = proxySystemProperties.proxyHost
+ try {
+ proxyPort = proxySystemProperties.proxyPort
+ } catch (e: ProxySystemProperties.IncorrectPortFormatException) {
+ proxyPort = -1
+ logger.warn(e.message!!)
+ }
+ proxyUser = proxySystemProperties.proxyUser
+ proxyPassword = proxySystemProperties.proxyPassword
+ }
+
+ /**
+ * Processes the command-line options for proxies.
+ *
+ * @return true if it has successfully processed the given option.
+ */
+ @Throws(AgentOptionParseException::class)
+ fun handleTeamscaleProxyOptions(key: String?, value: String): Boolean {
+ if ("host" == key) {
+ proxyHost = value
+ return true
+ }
+ val proxyPortOption = "port"
+ if (proxyPortOption == key) {
+ try {
+ proxyPort = value.toInt()
+ } catch (e: NumberFormatException) {
+ throw AgentOptionParseException(
+ "Could not parse proxy port \"$value\" set via \"$proxyPortOption\"", e
+ )
+ }
+ return true
+ }
+ if ("user" == key) {
+ proxyUser = value
+ return true
+ } else if ("password" == key) {
+ proxyPassword = value
+ return true
+ }
+ return false
+ }
+
+ /** Stores the teamscale-specific proxy settings as system properties to make them always available. */
+ fun putTeamscaleProxyOptionsIntoSystemProperties() {
+ val teamscaleProxySystemProperties = TeamscaleProxySystemProperties(protocol)
+ if (!isEmpty(proxyHost)) {
+ teamscaleProxySystemProperties.proxyHost = proxyHost
+ }
+ if (proxyPort > 0) {
+ teamscaleProxySystemProperties.proxyPort = proxyPort
+ }
+ if (!isEmpty(proxyUser)) {
+ teamscaleProxySystemProperties.proxyUser = proxyUser
+ }
+ if (!isEmpty(proxyPassword)) {
+ teamscaleProxySystemProperties.proxyPassword = proxyPassword
+ }
+
+ setProxyPasswordFromFile(proxyPasswordPath)
+ }
+
+ /**
+ * Sets the proxy password JVM property from a file for the protocol in this instance of
+ * [TeamscaleProxyOptions].
+ */
+ private fun setProxyPasswordFromFile(proxyPasswordFilePath: Path?) {
+ if (proxyPasswordFilePath == null) {
+ return
+ }
+ try {
+ val proxyPassword = readFileUTF8(proxyPasswordFilePath.toFile()).trim()
+ TeamscaleProxySystemProperties(protocol).proxyPassword = proxyPassword
+ } catch (e: IOException) {
+ logger.error(
+ "Unable to open file containing proxy password. Please make sure the file exists and the user has the permissions to read the file.",
+ e
+ )
+ }
+ }
+}
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/sapnwdi/DelayedSapNwdiMultiUploader.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/sapnwdi/DelayedSapNwdiMultiUploader.kt
new file mode 100644
index 000000000..1146931be
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/sapnwdi/DelayedSapNwdiMultiUploader.kt
@@ -0,0 +1,48 @@
+package com.teamscale.jacoco.agent.options.sapnwdi
+
+import com.teamscale.client.CommitDescriptor
+import com.teamscale.jacoco.agent.upload.DelayedMultiUploaderBase
+import com.teamscale.jacoco.agent.upload.IUploader
+import java.util.function.BiFunction
+
+/**
+ * Wraps multiple [IUploader]s to delay uploads until a [CommitDescriptor] is asynchronously made
+ * available for each application. Whenever a dump happens, the coverage is uploaded to all projects for which a
+ * corresponding commit has already been found. Uploads for application that have not committed at that time are skipped.
+ *
+ *
+ * This is safe assuming that the marker class is the central entry point for the application, and therefore there should
+ * not be any relevant coverage for the application as long as the marker class has not been loaded.
+ */
+class DelayedSapNwdiMultiUploader(
+ private val uploaderFactory: BiFunction
+) : DelayedMultiUploaderBase(), IUploader {
+ /** The wrapped uploader instances. */
+ private val uploaders = mutableMapOf()
+
+ /**
+ * Visible for testing. Allows tests to control the [Executor] to test the asynchronous functionality of this
+ * class.
+ */
+ init {
+ registerShutdownHook()
+ }
+
+ /** Registers the shutdown hook. */
+ private fun registerShutdownHook() {
+ Runtime.getRuntime().addShutdownHook(Thread {
+ if (wrappedUploaders.isEmpty()) {
+ logger.error("The application was shut down before a commit could be found. The recorded coverage is lost.")
+ }
+ })
+ }
+
+ /** Sets the commit info detected for the application. */
+ fun setCommitForApplication(commit: CommitDescriptor, application: SapNwdiApplication) {
+ logger.info("Found commit for ${application.markerClass}: $commit")
+ uploaders[application] = uploaderFactory.apply(commit, application)
+ }
+
+ override val wrappedUploaders: MutableCollection
+ get() = uploaders.values
+}
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/sapnwdi/SapNwdiApplication.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/sapnwdi/SapNwdiApplication.kt
new file mode 100644
index 000000000..4890f0f40
--- /dev/null
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/sapnwdi/SapNwdiApplication.kt
@@ -0,0 +1,51 @@
+package com.teamscale.jacoco.agent.options.sapnwdi
+
+import com.teamscale.jacoco.agent.options.AgentOptionParseException
+import java.util.*
+
+/**
+ * An SAP application that is identified by a [.markerClass] and refers to a corresponding Teamscale project.
+ */
+data class SapNwdiApplication(
+ /** A fully qualified class name that is used to match a jar file to this application. */
+ @JvmField val markerClass: String,
+ /** The teamscale project to which coverage should be uploaded. */
+ @JvmField val teamscaleProject: String
+) {
+ companion object {
+ /** Parses an application definition string e.g. "com.package.MyClass:projectId;com.company.Main:project". */
+ @JvmStatic
+ @Throws(AgentOptionParseException::class)
+ fun parseApplications(applications: String): List