diff --git a/README.md b/README.md index b46fdf4b..b64ead7f 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,91 @@ xtf.foo.v2.version=1.0.3 Retrieving an instance with this metadata: `Produts.resolve("product");` +#### Service Logs Streaming (SLS) +This feature allows for you to stream the services output while the test is running; this way you can see immediately +what is happening inside the cluster. +This is of great help when debugging provisioning, specifically on Cloud environments, which instead would require for +you to access your Pods. + +##### Kubernetes/OpenShift implementation +The SLS OpenShift platform implementation relies upon the following fabric8 Kubernetes Client API features: + +- Watching Kubernetes events (see + [PodWatchEquivalent.java](https://github.com/fabric8io/kubernetes-client/blob/master/kubernetes-examples/src/main/java/io/fabric8/kubernetes/examples/kubectl/equivalents/PodWatchEquivalent.java)) +- Watching Pod logs (see + [PodLogExample.java](https://github.com/fabric8io/kubernetes-client/blob/master/kubernetes-examples/src/main/java/io/fabric8/kubernetes/examples/PodLogExample.java)) + +The expected behavior is to stream the output of all the containers that are started or terminated in the selected +namespaces. + +##### Usage +The SLS feature can be configured and enabled either via annotations or via properties. +This behavior is provided by +[the ServiceLogsStreamingRunner JUnit 5 extension](./junit5/src/main/java/cz/xtf/junit5/extensions/ServiceLogsStreamingRunner.java). +There are two different ways for enabling the SLS functionality, which are summarized in the following sections, please +refer to the [JUnit 5 submodule documentation](./junit5/README.md) in order to read about the extension implementation +details. + +###### The `@ServiceLogsStreaming` annotation (Developer perspective) +Usage is as simple as annotating your test with `@ServiceLogsStreaming` e.g.: + +```java +@ServiceLogsStreaming +@Slf4j +public class HelloWorldTest { + // ... +} +``` + +###### The `xtf.log.streaming.enabled` and `xtf.log.streaming.config` property (Developer/Automation perspective) +You can enable the SLS feature by setting the `xtf.log.streaming.enabled` property so that it would apply to +all the test classes being executed. + +Conversely, if the above property is not set, you can set the `xtf.log.streaming.config` +property in order to provide multiple SLS configurations which could map to different test classes. + +The `xtf.log.streaming.config` property value is expected to be a _comma_ (`,`) separated list of _configuration items_, +each one formatted as a _semi-colon_ (`;`) separated list of _name_ and _value_ pairs for the above mentioned +attributes, where the name/value separator is expected to be the _equals_ char (`=`). +A single _configuration item_ represents a valid source of configuration for a single SLS activation and exposes the +following information: + +* _**target**_: a regular expression which allows for the testing engine to check whether the current context test class + name matches the Service Logs Streaming configuration - **REQUIRED** + +* _**filter**_: a string representing a _regex_ to filter out the resources which the Service Logs Streaming activation + should be monitoring - **OPTIONAL** + +* _**output**_: the base path where the log stream files - one for each executed test class - will be created. + **OPTIONAL**, if not assigned, logs will be streamed to `System.out`. When assigned, XTF will attempt to create the + path in case it doesn't exist and default to `System.out` should any error occur. + +###### Usage examples +Given what above, enabling SLS for all test classes is possible by executing the following command: + +```shell +mvn clean install -Dxtf.log.streaming.enabled=true +``` + +Similarly, in order to enable the feature for all test classes whose name is ending with "Test" should +be as simple as executing something similar to the following command: + +```shell +mvn clean install -Dxtf.log.streaming.config="target=.*Test" +``` + +which would differ in case the logs should be streamed to an output file: + +```shell +mvn clean install -Dxtf.log.streaming.config="target=.*Test;output=/home/myuser/sls-logs" +``` + +or in case you'd want to provide multiple configuration items to map different test classes, e.g.: + +```shell +mvn clean install -Dxtf.log.streaming.config="target=TestClassA,target=TestClassB.*;output=/home/myuser/sls-logs;filter=.*my-app.*" +``` + ### JUnit5 JUnit5 module provides a number of extensions and listeners designed to easy up OpenShift images test management. See [JUnit5](https://github.com/xtf-cz/xtf/blob/master/core/src/main/java/cz/xtf/core/waiting/SimpleWaiter.java) for diff --git a/core/src/main/java/cz/xtf/core/service/logs/streaming/AnnotationBasedServiceLogsConfigurations.java b/core/src/main/java/cz/xtf/core/service/logs/streaming/AnnotationBasedServiceLogsConfigurations.java new file mode 100644 index 00000000..077b774b --- /dev/null +++ b/core/src/main/java/cz/xtf/core/service/logs/streaming/AnnotationBasedServiceLogsConfigurations.java @@ -0,0 +1,39 @@ +package cz.xtf.core.service.logs.streaming; + +import java.util.HashMap; +import java.util.Map; + +import lombok.extern.slf4j.Slf4j; + +/** + * Implements {@link ServiceLogsSettings} in order to provide the concrete logic to handle SLS configuration based + * on the {@link ServiceLogsStreaming} annotation. + */ +@Slf4j +public class AnnotationBasedServiceLogsConfigurations { + + private static final Map CONFIGURATIONS = new HashMap<>(); + + private ServiceLogsSettings loadConfiguration(Class testClazz) { + ServiceLogsStreaming annotation = testClazz.getAnnotation(ServiceLogsStreaming.class); + if (annotation != null) { + return new ServiceLogsSettings.Builder() + .withTarget(testClazz.getName()) + .withFilter(annotation.filter()) + .withOutputPath(annotation.output()) + .build(); + } + return null; + } + + public ServiceLogsSettings forClass(Class testClazz) { + ServiceLogsSettings serviceLogsSettings = CONFIGURATIONS.get(testClazz.getName()); + if (serviceLogsSettings == null) { + serviceLogsSettings = loadConfiguration(testClazz); + if (serviceLogsSettings != null) { + CONFIGURATIONS.put(testClazz.getName(), serviceLogsSettings); + } + } + return serviceLogsSettings; + } +} diff --git a/core/src/main/java/cz/xtf/core/service/logs/streaming/ServiceLogColor.java b/core/src/main/java/cz/xtf/core/service/logs/streaming/ServiceLogColor.java new file mode 100644 index 00000000..3ff90281 --- /dev/null +++ b/core/src/main/java/cz/xtf/core/service/logs/streaming/ServiceLogColor.java @@ -0,0 +1,55 @@ +package cz.xtf.core.service.logs.streaming; + +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +/** + * This enum contains the colors that are used when logging container's log on the console; each container gets a + * different color in order to visually differentiate containers; the background is also colored in order to + * differentiate application log from containers log. + */ +public enum ServiceLogColor { + ANSI_RESET_FG("\u001B[0m"), + ANSI_RESET_BG("\u001b[0m"), + ANSI_POD_LOG_BG("\u001b[40m"), + ANSI_RED1("\u001B[31m"), + ANSI_GREEN1("\u001B[32m"), + ANSI_YELLOW1("\u001B[33m"), + ANSI_AZURE1("\u001B[34m"), + ANSI_VIOLET1("\u001B[35m"), + ANSI_WATER1("\u001B[36m"), + ANSI_BRIGHT_RED("\u001B[91m"), + ANSI_BRIGHT_GREEN("\u001B[92m"), + ANSI_BRIGHT_YELLOW("\u001B[93m"), + ANSI_BRIGHT_BLUE("\u001B[94m"), + ANSI_BRIGHT_PURPLE("\u001B[95m"), + ANSI_BRIGHT_CYAN("\u001B[96m"), + ANSI_BRIGHT_WHITE("\u001B[97m"), + ANSI_POD_NAME_BG("\u001b[40;3m"); + + public final String value; + private static final Lock lock = new ReentrantLock(); + private static int idx = 0; + + public static final ServiceLogColor[] COLORS = { + ANSI_BRIGHT_GREEN, ANSI_BRIGHT_RED, ANSI_BRIGHT_YELLOW, + ANSI_BRIGHT_BLUE, ANSI_BRIGHT_PURPLE, ANSI_BRIGHT_CYAN, ANSI_BRIGHT_WHITE, + ANSI_RED1, ANSI_GREEN1, ANSI_YELLOW1, ANSI_AZURE1, ANSI_VIOLET1, ANSI_WATER1 + }; + + private ServiceLogColor(String value) { + this.value = value; + } + + public static ServiceLogColor getNext() { + lock.lock(); + try { + if (++idx >= COLORS.length) { + idx = 0; + } + return COLORS[idx]; + } finally { + lock.unlock(); + } + } +} diff --git a/core/src/main/java/cz/xtf/core/service/logs/streaming/ServiceLogColoredPrintStream.java b/core/src/main/java/cz/xtf/core/service/logs/streaming/ServiceLogColoredPrintStream.java new file mode 100644 index 00000000..0983f60f --- /dev/null +++ b/core/src/main/java/cz/xtf/core/service/logs/streaming/ServiceLogColoredPrintStream.java @@ -0,0 +1,199 @@ +package cz.xtf.core.service.logs.streaming; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InterruptedIOException; +import java.io.OutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class ServiceLogColoredPrintStream extends PrintStream { + private static final Logger logger = LoggerFactory.getLogger(ServiceLogColoredPrintStream.class); + private final ServiceLogColor color; + private final String prefix; + private boolean needHeader = true; + + /** + * Constructor has private access because you are supposed to used the + * {@link Builder} + */ + private ServiceLogColoredPrintStream(OutputStream out, ServiceLogColor color, String prefix) { + // this class just writes to `out` so its life cycle, which must be handled at the outer scope, is not altered + // at all + super(out, true); + this.color = color; + this.prefix = prefix; + } + + /** + * Splits the current buffer into tokens; the criteria for splitting consists in considering each new line as a + * token and everything else as another token. + * + * If we have e.g.: "AAAA\nBBBB", we would have 3 tokens: "AAAA","\n","BBBB"; + * Note that a buffer not containing any new line is considered a single token e.g. buffer "AAAA" is considered as + * the single token "AAAA" + * + * @param buf the data. + * @param off the start offset in the data. + * @param len the number of bytes to write. + * @return the list of tokens in the data; joining the tokens you get the original data. + * @throws IOException in case something goes wrong while managing streams internally + */ + protected List getTokens(byte buf[], int off, int len) throws IOException { + final List tokens = new ArrayList<>(); + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + for (int i = off; i < off + len; i++) { + if ((char) buf[i] == '\n') { + if (baos.size() > 0) { + tokens.add(baos.toByteArray()); + baos.reset(); + } + tokens.add(Arrays.copyOfRange(buf, i, i + 1)); + } else { + baos.write(buf[i]); + } + } + if (baos.size() > 0) + tokens.add(baos.toByteArray()); + } + if (tokens.isEmpty()) + tokens.add(Arrays.copyOfRange(buf, off, len)); + return tokens; + } + + private boolean isNewLine(byte[] token) { + return token != null && token.length == 1 && (char) token[0] == '\n'; + } + + /** + * Prints the line header which usually consists of the name of the container the log comes from, preceded by the + * name of the pod and the name of the namespace where the container is running, with properly colored background + * and foreground + * + * @throws IOException in case something goes wrong while writing to the underlying stream + */ + private void printLineHeader() throws IOException { + // set line header foreground and background + out.write(color.value.getBytes(StandardCharsets.UTF_8)); + out.write(ServiceLogColor.ANSI_POD_NAME_BG.value.getBytes(StandardCharsets.UTF_8)); + // actual line header + out.write(ServiceLogUtils.formatStreamedLogLine(prefix).getBytes(StandardCharsets.UTF_8)); + // reset foreground and background + out.write(ServiceLogColor.ANSI_RESET_FG.value.getBytes(StandardCharsets.UTF_8)); + out.write(ServiceLogColor.ANSI_RESET_BG.value.getBytes(StandardCharsets.UTF_8)); + out.flush(); + } + + /** + * Prints a token of the log line with properly colored background and foreground + * + * @param buf the data. + * @param off the start offset in the data. + * @param len the number of bytes to write. + * @throws IOException in case something goes wrong writing to the underlying stream + */ + private void printToken(byte buf[], int off, int len) throws IOException { + // set log token foreground and background + out.write(ServiceLogColor.ANSI_POD_LOG_BG.value.getBytes(StandardCharsets.UTF_8)); + out.write(color.value.getBytes(StandardCharsets.UTF_8)); + // actual log token + out.write(buf, off, len); + // reset foreground and background + out.write(ServiceLogColor.ANSI_RESET_FG.value.getBytes(StandardCharsets.UTF_8)); + out.write(ServiceLogColor.ANSI_RESET_BG.value.getBytes(StandardCharsets.UTF_8)); + out.flush(); + } + + @Override + public void write(int b) { + write(new byte[] { (byte) b }, 0, 1); + } + + /** + * Prints the data with properly colored background and foreground; takes care of adding a line header to each line + * + * @param buf the data. + * @param off the start offset in the data. + * @param len the number of bytes to write. + */ + @Override + public void write(byte buf[], int off, int len) { + try { + synchronized (out) { + if (out == null) + throw new IOException("Stream is closed"); + + logger.trace("TOKEN_FROM_CONTAINER: {}", new String(Arrays.copyOfRange(buf, off, len))); + + // first line ever --> print header + if (needHeader) { + printLineHeader(); + needHeader = false; + } + // split by newline + List tokens = getTokens(buf, off, len); + if (!tokens.isEmpty()) { + for (int i = 0; i < tokens.size(); i++) { + if (isNewLine(tokens.get(i))) { + out.write('\n'); + // print the line header unless it's the last token: in that case set the reminder for the next + if (i < tokens.size() - 1) { + printLineHeader(); + } else { + needHeader = true; + } + } else { + printToken(tokens.get(i), 0, tokens.get(i).length); + } + } + } + } + } catch (InterruptedIOException x) { + Thread.currentThread().interrupt(); + } catch (IOException x) { + setError(); + } + } + + public static class Builder { + private OutputStream outputStream; + private ServiceLogColor color; + private String prefix; + + public Builder outputTo(final OutputStream outputStream) { + this.outputStream = outputStream; + return this; + } + + public Builder withColor(final ServiceLogColor color) { + this.color = color; + return this; + } + + public Builder withPrefix(final String prefix) { + this.prefix = prefix; + return this; + } + + public ServiceLogColoredPrintStream build() { + if (outputStream == null) { + throw new IllegalStateException("OutputStream must be specified!"); + } + if (color == null) { + throw new IllegalStateException("Color must be specified!"); + } + if (prefix == null) { + throw new IllegalStateException("Prefix must be specified!"); + } + return new ServiceLogColoredPrintStream(outputStream, color, prefix); + } + + } + +} diff --git a/core/src/main/java/cz/xtf/core/service/logs/streaming/ServiceLogUtils.java b/core/src/main/java/cz/xtf/core/service/logs/streaming/ServiceLogUtils.java new file mode 100644 index 00000000..844c2f38 --- /dev/null +++ b/core/src/main/java/cz/xtf/core/service/logs/streaming/ServiceLogUtils.java @@ -0,0 +1,31 @@ +package cz.xtf.core.service.logs.streaming; + +/** + * Helper class exposing utilities to the Service Logs Streaming component. + */ +public class ServiceLogUtils { + + public static final String XTF_SLS_HEADER_LABEL = "XTF Service Logs Streaming"; + + /** + * Adds a prefix to the message which is passed in, in order to display that it is part of the logs streaming + * process, i.e. by adding a conventional part to the beginning of the message. + * + * @param originalMessage the original message + * @return A string made up by the conventional prefix, followed by the original message + */ + public static String getConventionallyPrefixedLogMessage(final String originalMessage) { + return String.format("[" + XTF_SLS_HEADER_LABEL + "] > %s", originalMessage); + } + + /** + * Formats the given prefix into a conventional header for a any streamed log line so that it is clear to the end + * user that it is coming from a service log rather than from the current process one + * + * @param prefix The prefix that will be part of the header + * @return A string representing the header for a streamed log line which will be written to the output stream + */ + public static String formatStreamedLogLine(final String prefix) { + return String.format("[" + XTF_SLS_HEADER_LABEL + " - %s] > ", prefix); + } +} diff --git a/core/src/main/java/cz/xtf/core/service/logs/streaming/ServiceLogs.java b/core/src/main/java/cz/xtf/core/service/logs/streaming/ServiceLogs.java new file mode 100644 index 00000000..0480c3e0 --- /dev/null +++ b/core/src/main/java/cz/xtf/core/service/logs/streaming/ServiceLogs.java @@ -0,0 +1,16 @@ +package cz.xtf.core.service.logs.streaming; + +/** + * Defines the contract to manage the life cycle of a cloud Service Logs Streaming component + */ +public interface ServiceLogs { + /** + * Start watching Service logs + */ + void start(); + + /** + * Stop watching Service logs + */ + void stop(); +} diff --git a/core/src/main/java/cz/xtf/core/service/logs/streaming/ServiceLogsSettings.java b/core/src/main/java/cz/xtf/core/service/logs/streaming/ServiceLogsSettings.java new file mode 100644 index 00000000..d5086216 --- /dev/null +++ b/core/src/main/java/cz/xtf/core/service/logs/streaming/ServiceLogsSettings.java @@ -0,0 +1,105 @@ +package cz.xtf.core.service.logs.streaming; + +import java.util.Objects; + +/** + * Stores a valid Service Logs Streaming component configuration + */ +public class ServiceLogsSettings { + + public static final String ATTRIBUTE_NAME_TARGET = "target"; + public static final String ATTRIBUTE_NAME_FILTER = "filter"; + public static final String ATTRIBUTE_NAME_OUTPUT = "output"; + public static final String UNASSIGNED = "[unassigned]"; + + private final String target; + private final String filter; + private final String outputPath; + + private ServiceLogsSettings(String target, String filter, String outputPath) { + this.target = target; + this.filter = filter; + this.outputPath = outputPath; + } + + /** + * Regular expression which defines the test classes whose services logs must be streamed - e.g. allows the testing + * engine to check whether a given context test class name is a valid target for a Service Logs Streaming + * configuration. + * + * @return String representing a regular expression which defines the test classes whose services logs must be + * streamed. + */ + public String getTarget() { + return target; + } + + /** + * Regex to filter out the resources which the Service Logs Streaming activation should be monitoring. + * + * @return String representing a regex to filter out the resources which the Service Logs Streaming activation + * should be monitoring. + */ + public String getFilter() { + return filter; + } + + /** + * Base path which should be used as the output where the logs stream files must be written + * + * @return String identifying a base path to which the service logs output files should be streamed. + */ + public String getOutputPath() { + return outputPath; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + ServiceLogsSettings that = (ServiceLogsSettings) o; + return target.equals(that.target) && filter.equals(that.filter) + && outputPath.equals(that.outputPath); + } + + @Override + public int hashCode() { + return Objects.hash(target, filter, outputPath); + } + + public static final class Builder { + private String target; + private String filter; + private String outputPath; + + public Builder withTarget(String target) { + this.target = target; + return this; + } + + public Builder withFilter(String filter) { + this.filter = filter; + return this; + } + + public Builder withOutputPath(String outputPath) { + this.outputPath = outputPath; + return this; + } + + public ServiceLogsSettings build() { + if ((target == null) || target.isEmpty()) { + throw new IllegalStateException("The Service Logs Streaming settings must define a target regex"); + } + if ((filter == null) || filter.isEmpty()) { + filter = ServiceLogsSettings.UNASSIGNED; + } + if ((outputPath == null) || outputPath.isEmpty()) { + outputPath = ServiceLogsSettings.UNASSIGNED; + } + return new ServiceLogsSettings(target, filter, outputPath); + } + } +} diff --git a/core/src/main/java/cz/xtf/core/service/logs/streaming/ServiceLogsStreaming.java b/core/src/main/java/cz/xtf/core/service/logs/streaming/ServiceLogsStreaming.java new file mode 100644 index 00000000..1088f8e8 --- /dev/null +++ b/core/src/main/java/cz/xtf/core/service/logs/streaming/ServiceLogsStreaming.java @@ -0,0 +1,31 @@ +package cz.xtf.core.service.logs.streaming; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * This interfaces defines the annotation which can be used to enable the Service Logs Streaming component. + * What log(s) can be inspected and how is determined by the type of service and its life cycle (e.g.: accessing logs + * in the provisioning or execution phases) and by the type of cloud platform in which the service is living + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface ServiceLogsStreaming { + /** + * Returns the log level filter which is set for a service which is annotated with {@link ServiceLogsStreaming} + * + * @return A String identifying the log level filter which is set for a service which is annotated with + * {@link ServiceLogsStreaming} + */ + String filter() default ServiceLogsSettings.UNASSIGNED; + + /** + * Defines the path of a file where the logs of a service which is annotated with {@link ServiceLogsStreaming} should be + * streamed + * + * @return A String representing the path of a file to where the service logs should be streamed. + */ + String output() default ServiceLogsSettings.UNASSIGNED; +} diff --git a/core/src/main/java/cz/xtf/core/service/logs/streaming/SystemPropertyBasedServiceLogsConfigurations.java b/core/src/main/java/cz/xtf/core/service/logs/streaming/SystemPropertyBasedServiceLogsConfigurations.java new file mode 100644 index 00000000..7364fd9c --- /dev/null +++ b/core/src/main/java/cz/xtf/core/service/logs/streaming/SystemPropertyBasedServiceLogsConfigurations.java @@ -0,0 +1,124 @@ +package cz.xtf.core.service.logs.streaming; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * Implements {@link ServiceLogsSettings} in order to provide the concrete logic to handle Service Logs + * Streaming configuration based on the {@code xtf.log.streaming.config} system property. + */ +public class SystemPropertyBasedServiceLogsConfigurations { + + /* + * The property format should match the following format: + * + * ^(name=value(;name=value)*)(,(name=value(;name=value)*))*$ + * + * i.e. a comma (,) separated list pof configuration items, each of which is a semi-colon (;) separated list of name + * and value pairs, separated by an equal character (=) + */ + private static final String CONFIGURATION_PROPERTY_ITEMS_SEPARATOR = ","; + private static final String CONFIGURATION_PROPERTY_ATTRIBUTE_SEPARATOR = ";"; + private static final String CONFIGURATION_PROPERTY_NAME_VALUE_SEPARATOR = "="; + // attribute names regex + private static final String ALLOWED_ATTRIBUTE_NAME_REGEX = String.format("%s|%s|%s", + ServiceLogsSettings.ATTRIBUTE_NAME_TARGET, + ServiceLogsSettings.ATTRIBUTE_NAME_FILTER, + ServiceLogsSettings.ATTRIBUTE_NAME_OUTPUT); + // attributes values regex (which must allow for adding basically any char plus special ones, as for providing + // regex'es + private static final String ALLOWED_ATTRIBUTE_VALUE_REGEX = "[\\\\|\\w|^|$|.|+|?|*|\\[|\\]|(|)|{|}|&|\\-|>|<|/|:]"; + // full attribute (name=value) regex + private static final String ALLOWED_ATTRIBUTE_REGEX = "(?:" + ALLOWED_ATTRIBUTE_NAME_REGEX + ")" + + CONFIGURATION_PROPERTY_NAME_VALUE_SEPARATOR + + ALLOWED_ATTRIBUTE_VALUE_REGEX + "+"; + // a configuration item regex, i.e. 1..N attributes, separated by semi-colon (;) + private static final String ALLOWED_ITEM_REGEX = "(" + + ALLOWED_ATTRIBUTE_REGEX + + "(?:" + CONFIGURATION_PROPERTY_ATTRIBUTE_SEPARATOR + ALLOWED_ATTRIBUTE_REGEX + ")*" + + ")"; + + private final String serviceLogsStreamingConfigProperty; + private final Map configurations; + + public SystemPropertyBasedServiceLogsConfigurations(String serviceLogsStreamingConfigProperty) { + if ((serviceLogsStreamingConfigProperty == null) || serviceLogsStreamingConfigProperty.isEmpty()) { + throw new IllegalArgumentException( + "A valid configuration must be provided by setting \"xtf.log.streaming.config\" value, in order to initialize a \"SystemPropertyBasedServiceLogsConfigurations\" instance"); + } + this.serviceLogsStreamingConfigProperty = serviceLogsStreamingConfigProperty; + configurations = loadConfigurations(); + } + + private Map loadConfigurations() { + return Stream.of(serviceLogsStreamingConfigProperty.split(CONFIGURATION_PROPERTY_ITEMS_SEPARATOR)) + .map(configurationItem -> getSettingsFromItemConfiguration(configurationItem)) + .collect(Collectors.toMap(serviceLogsSettings -> serviceLogsSettings.getTarget(), + serviceLogsSettings -> serviceLogsSettings)); + } + + private static ServiceLogsSettings getSettingsFromItemConfiguration(String configurationItem) { + // validate the item config + if (!configurationItem.matches(ALLOWED_ITEM_REGEX)) { + throw new IllegalArgumentException( + String.format( + "The value of the \"xtf.log.streaming.config\" property items must match the following format: %s. Was: %s" + + ALLOWED_ITEM_REGEX, + configurationItem)); + } + // get all attributes for an item + final String[] configurationAttributes = configurationItem.split(CONFIGURATION_PROPERTY_ATTRIBUTE_SEPARATOR); + // prepare a builder for this item configuration + final ServiceLogsSettings.Builder builder = new ServiceLogsSettings.Builder(); + + Arrays.stream(configurationAttributes).map(a -> a.split(CONFIGURATION_PROPERTY_NAME_VALUE_SEPARATOR)) + .forEach(nameValuePair -> { + final String attributeName = nameValuePair[0]; + final String attributeValue = nameValuePair[1]; + toBuilder(builder, attributeName, attributeValue); + }); + return builder.build(); + } + + private static ServiceLogsSettings.Builder toBuilder(ServiceLogsSettings.Builder theBuilder, String attributeName, + String attributeValue) { + switch (attributeName) { + case ServiceLogsSettings.ATTRIBUTE_NAME_TARGET: + theBuilder.withTarget(attributeValue); + break; + case ServiceLogsSettings.ATTRIBUTE_NAME_FILTER: + theBuilder.withFilter(attributeValue); + break; + case ServiceLogsSettings.ATTRIBUTE_NAME_OUTPUT: + theBuilder.withOutputPath(attributeValue); + break; + default: + throw new IllegalArgumentException( + String.format( + "Unconventional configuration attribute name: %s. Allowed configuration attributes names: (%s)", + attributeName, ALLOWED_ATTRIBUTE_NAME_REGEX)); + } + return theBuilder; + } + + // just for test purposes, at the moment + Collection list() { + return configurations.values(); + } + + public ServiceLogsSettings forClass(Class testClazz) { + Optional> selectedConfigurationSearch = configurations.entrySet().stream() + .filter(c -> testClazz.getName().equals(c.getKey())) + .findFirst(); + if (!selectedConfigurationSearch.isPresent()) { + selectedConfigurationSearch = configurations.entrySet().stream() + .filter(c -> testClazz.getName().matches(c.getKey())) + .findFirst(); + } + return selectedConfigurationSearch.isPresent() ? selectedConfigurationSearch.get().getValue() : null; + } +} diff --git a/core/src/main/java/cz/xtf/core/service/logs/streaming/k8s/PodLogs.java b/core/src/main/java/cz/xtf/core/service/logs/streaming/k8s/PodLogs.java new file mode 100644 index 00000000..3f35ca90 --- /dev/null +++ b/core/src/main/java/cz/xtf/core/service/logs/streaming/k8s/PodLogs.java @@ -0,0 +1,128 @@ +package cz.xtf.core.service.logs.streaming.k8s; + +import java.io.PrintStream; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; + +import cz.xtf.core.service.logs.streaming.ServiceLogs; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.Watch; +import lombok.extern.slf4j.Slf4j; + +/** + * Concrete implementation of {@link ServiceLogs} for the Cloud/k8s environment, i.e. it deals with services running + * on Pods. + * + * Watches Pods on multiple namespaces and streams logs for all Pods containers to the selected + * {@link PrintStream}. + * Each log is prefixed with namespace.pod-name.container-name and colored differently. + */ +@Slf4j +public class PodLogs implements ServiceLogs { + private KubernetesClient client; + private List namespaces; + private PrintStream printStream; + private Pattern filter; + private List watches; + + private PodLogs(KubernetesClient client, List namespaces, PrintStream printStream, Pattern filter) { + this.client = client; + this.namespaces = namespaces; + // The life cycle of the PrintStream instance that is being used must be handled in the outer scope, which is + // not here. + // Here it will be either passed (i.e. printStream != null) or set by default to System.out, which is created + // somewhere else. + // For this reason it is not altered at all. + this.printStream = printStream == null ? System.out : printStream; + this.filter = filter; + this.watches = new ArrayList<>(); + } + + /** + * Start watching Pods logs in all the specified namespaces + */ + @Override + public void start() { + if (namespaces != null && !namespaces.isEmpty()) { + for (String namespace : namespaces) { + log.info( + "============================================================================================================================="); + log.info( + "Service Logs Streaming (SLS) was started in order to stream logs belonging to all pods in the {} namespace", + namespace); + log.info( + "============================================================================================================================="); + Watch watch = client.pods().inNamespace(namespace).watch( + new PodLogsWatcher.Builder() + .withClient(client) + .inNamespace(namespace) + .outputTo(printStream) + .filter(filter) + .build()); + watches.add(watch); + } + } + } + + /** + * Stop watching Pods logs in all specified namespaces + */ + @Override + public void stop() { + for (Watch watch : watches) { + watch.close(); + } + } + + public static class Builder { + private KubernetesClient client; + private List namespaces; + private PrintStream printStream; + private Pattern filter; + + public Builder withClient(final KubernetesClient client) { + this.client = client; + return this; + } + + public Builder inNamespaces(final List namespaces) { + this.namespaces = namespaces; + return this; + } + + /** + * Gets a {@link PrintStream} instance that will be passed on and used by the {@link PodLogs} instance + * + * @param printStream A {@link PrintStream} instance which will be used for streaming the service logs. + * It is expected that the {@link PrintStream} instance life cycle will be handled + * at the outer scope level, in the context and by the component which created it. + * @return A {@link Builder} instance with the {@code outputTo} property set to the given value. + */ + public Builder outputTo(final PrintStream printStream) { + this.printStream = printStream; + return this; + } + + public Builder filter(final String filter) { + if (filter != null) { + this.filter = Pattern.compile(filter); + } + return this; + } + + public PodLogs build() { + if (client == null) { + throw new IllegalStateException("The Kubernetes client must be specified!"); + } + if (namespaces == null) { + throw new IllegalStateException("A list of namespaces must be specified!"); + } + if (printStream == null) { + throw new IllegalStateException("A target print stream must be specified!"); + } + + return new PodLogs(client, namespaces, printStream, filter); + } + } +} diff --git a/core/src/main/java/cz/xtf/core/service/logs/streaming/k8s/PodLogsWatcher.java b/core/src/main/java/cz/xtf/core/service/logs/streaming/k8s/PodLogsWatcher.java new file mode 100644 index 00000000..3ac9cd7c --- /dev/null +++ b/core/src/main/java/cz/xtf/core/service/logs/streaming/k8s/PodLogsWatcher.java @@ -0,0 +1,290 @@ +package cz.xtf.core.service.logs.streaming.k8s; + +import static java.util.stream.Collectors.toList; + +import java.io.PrintStream; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import cz.xtf.core.service.logs.streaming.ServiceLogColor; +import cz.xtf.core.service.logs.streaming.ServiceLogColoredPrintStream; +import cz.xtf.core.service.logs.streaming.ServiceLogUtils; +import io.fabric8.kubernetes.api.model.ContainerStatus; +import io.fabric8.kubernetes.api.model.Pod; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.Watcher; +import io.fabric8.kubernetes.client.WatcherException; +import io.fabric8.kubernetes.client.dsl.LogWatch; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; + +/** + * Watches Pods on a single namespace and streams logs for all Pods containers to the selected + * {@link PrintStream}. + * + * Each log is prefixed with namespace.pod-name.container-name and colored differently. + */ +@Slf4j +class PodLogsWatcher implements Watcher { + private static final int LOG_TAILING_LINES = 100; + private static final int LOG_WAIT_TIMEOUT = 60000; + private KubernetesClient client; + private String namespace; + private PrintStream printStream; + private Pattern filter; + + protected PodLogsWatcher(KubernetesClient client, String namespace, PrintStream printStream, Pattern filter) { + this.client = client; + this.namespace = namespace; + // The life cycle of the PrintStream instance that is being used must be handled in the outer scope, which is + // not here. + // Here it is just passed in to be used, hence not altered at all. + this.printStream = printStream; + this.filter = filter; + } + + private List runningStatusesBefore = Collections.emptyList(); + private List terminatedStatusesBefore = Collections.emptyList(); + private final Map logWatches = new HashMap<>(); + + /** + * Handles newly running containers + * + * @param pod the Pod where to look for newly running containers + */ + private void handleNewRunningContainers(Pod pod) { + // existing containers running statuses + List existingContainersRunningStatuses = getRunningContainersStatuses(pod); + log.debug(ServiceLogUtils.getConventionallyPrefixedLogMessage( + String.format("existingContainersRunningStatuses.size=%s names=%s existingContainersRunningStatuses=%s", + existingContainersRunningStatuses.size(), + existingContainersRunningStatuses.stream().map(cs -> cs.getName()).collect(Collectors.joining()), + existingContainersRunningStatuses))); + // newly running containers statuses + List newContainersRunningStatuses = getNewContainers(runningStatusesBefore, + existingContainersRunningStatuses); + log.debug(ServiceLogUtils.getConventionallyPrefixedLogMessage( + String.format("newContainersRunningStatuses.size=%s names=%s newContainersRunningStatuses=%s", + newContainersRunningStatuses.size(), + newContainersRunningStatuses.stream().map(cs -> cs.getName()).collect(Collectors.joining()), + newContainersRunningStatuses))); + // let's update the currently running containers + runningStatusesBefore = existingContainersRunningStatuses; + // now let's create and start the LogWatch instances that will monitor the containers' logs + for (ContainerStatus status : newContainersRunningStatuses) { + log.info(ServiceLogUtils.getConventionallyPrefixedLogMessage( + String.format("Container %s.%s.%s running...", namespace, pod.getMetadata().getName(), status.getName()))); + log.debug(ServiceLogUtils.getConventionallyPrefixedLogMessage( + String.format( + "CONTAINER status: name=%s, started=%s, ready=%s \n\t waiting=%s \n\t running=%s \n\t terminated=%s \n\t complete status=%s", + status.getName(), + status.getStarted(), + status.getReady(), + status.getState().getWaiting(), + status.getState().getRunning(), + status.getState().getTerminated(), + pod.getStatus()))); + if (filter != null && filter.matcher(pod.getMetadata().getName()).matches()) { + log.info(ServiceLogUtils.getConventionallyPrefixedLogMessage( + String.format("Skipped Pod %s.%s", namespace, pod.getMetadata().getName()))); + continue; + } + if (filter != null && filter.matcher(status.getName()).matches()) { + log.info(ServiceLogUtils.getConventionallyPrefixedLogMessage( + String.format("Skipped Container %s.%s.%s", namespace, pod.getMetadata().getName(), status.getName()))); + continue; + } + final LogWatch lw = client.pods().inNamespace(namespace).withName(pod.getMetadata().getName()) + .inContainer(status.getName()) + .tailingLines(LOG_TAILING_LINES) + .withLogWaitTimeout(LOG_WAIT_TIMEOUT) + .watchLog( + new ServiceLogColoredPrintStream.Builder() + .outputTo(printStream) + .withColor(ServiceLogColor.getNext()) + .withPrefix( + forgeContainerLogPrefix(pod, status)) + .build()); + logWatches.put(status.getContainerID(), lw); + log.debug(ServiceLogUtils.getConventionallyPrefixedLogMessage( + String.format("PodLogsWatcher started for Container %s in Pod %s in Namespace %s", + status.getName(), + pod.getMetadata().getName(), + namespace))); + } + } + + private String forgeContainerLogPrefix(Pod pod, ContainerStatus status) { + return String.format("%s.%s.%s", + namespace, + pod.getMetadata().getName(), + status.getName()); + } + + /** + * Handles newly terminated containers + * + * @param pod the Pod where to look for newly terminated containers + */ + private void handleNewTerminatedContainers(Pod pod) { + // existing containers terminated statuses + List existingContainersTerminatedStatuses = getTerminatedContainers(pod); + log.debug(ServiceLogUtils.getConventionallyPrefixedLogMessage( + String.format("existingContainersTerminatedStatuses.size=%s names=%s existingContainersTerminatedStatuses=%s", + existingContainersTerminatedStatuses.size(), + existingContainersTerminatedStatuses.stream().map(cs -> cs.getName()).collect(Collectors.joining()), + existingContainersTerminatedStatuses))); + // newly terminated containers statuses + List newContainersTerminatedStatuses = getNewContainers(terminatedStatusesBefore, + existingContainersTerminatedStatuses); + log.debug(ServiceLogUtils.getConventionallyPrefixedLogMessage( + String.format("newContainersTerminatedStatuses.size=%s names=%s newContainersTerminatedStatuses=%s", + newContainersTerminatedStatuses.size(), + newContainersTerminatedStatuses.stream().map(cs -> cs.getName()).collect(Collectors.joining()), + newContainersTerminatedStatuses))); + // let's update the currently terminated containers + terminatedStatusesBefore = existingContainersTerminatedStatuses; + // now let's terminate the LogWatch instances that are monitoring the containers' logs + for (ContainerStatus status : newContainersTerminatedStatuses) { + log.info(ServiceLogUtils.getConventionallyPrefixedLogMessage( + String.format("Container %s.%s.%s was terminated!", namespace, pod.getMetadata().getName(), + status.getName()))); + if (logWatches.containsKey(status.getContainerID())) { + // the log watch must be closed so that internal resources are managed properly (e.g.: + // `Closeable` instances will be closed) + logWatches.get(status.getContainerID()).close(); + log.debug(ServiceLogUtils.getConventionallyPrefixedLogMessage("Terminating PodLogsWatcher")); + logWatches.remove(status.getContainerID()); + } + } + } + + @SneakyThrows + @Override + public void eventReceived(Action action, Pod pod) { + log.debug(ServiceLogUtils.getConventionallyPrefixedLogMessage( + String.format("%s %s: %s", action.name(), pod.getMetadata().getName(), pod.getStatus()))); + switch (action) { + case ADDED: + handleNewRunningContainers(pod); + break; + case MODIFIED: + handleNewRunningContainers(pod); + handleNewTerminatedContainers(pod); + break; + case DELETED: + case ERROR: + handleNewTerminatedContainers(pod); + break; + default: + log.error(ServiceLogUtils.getConventionallyPrefixedLogMessage( + String.format("Unrecognized event: %s", action.name()))); + } + } + + /** + * Gets just new containers statuses by filtering the existing ones out. + * + * @param before A list of {@link ContainerStatus} instances belonging to already existing containers + * @param now A list of {@link ContainerStatus} instances belonging to new containers + * @return A list of {@link ContainerStatus} instances representing the difference between those belonging to + * new containers and the existing ones, at a given moment in time. + */ + private List getNewContainers(final List before, final List now) { + List namesBefore = before.stream().map(cs -> cs.getContainerID()).collect(Collectors.toList()); + return now.stream() + .filter(element -> !namesBefore.contains(element.getContainerID())) + .collect(Collectors.toList()); + } + + /** + * Returns all the terminated statuses belonging to containers inside a Pod + * + * @param pod a {@link Pod} instance where to look for containers + * @return A list of containers running statuses related to containers belonging to the {@link Pod} instance + */ + private List getTerminatedContainers(final Pod pod) { + final List containers = new ArrayList<>(); + containers.addAll( + pod.getStatus().getInitContainerStatuses().stream().filter( + containerStatus -> containerStatus.getState().getTerminated() != null).collect(toList())); + containers.addAll( + pod.getStatus().getContainerStatuses().stream().filter( + containerStatus -> containerStatus.getState().getTerminated() != null).collect(toList())); + return containers; + } + + /** + * Returns all the running statuses belonging to containers inside a Pod + * + * @param pod a {@link Pod} instance where to look for containers + * @return A list of containers running statuses related to containers belonging to the {@link Pod} instance + */ + private List getRunningContainersStatuses(final Pod pod) { + final List statuses = new ArrayList<>(); + statuses.addAll( + pod.getStatus().getInitContainerStatuses().stream().filter( + containerStatus -> containerStatus.getState().getRunning() != null).collect(toList())); + statuses.addAll( + pod.getStatus().getContainerStatuses().stream().filter( + containerStatus -> containerStatus.getState().getRunning() != null).collect(toList())); + return statuses; + } + + @Override + public void onClose(WatcherException e) { + logWatches.forEach( + (s, logWatch) -> { + // the log watch must be closed so that internal resources are managed properly (e.g.: + // `Closeable` instances will be closed) + logWatch.close(); + }); + log.debug(ServiceLogUtils.getConventionallyPrefixedLogMessage("Terminating PodLogsWatcher")); + } + + static class Builder { + private KubernetesClient client; + private String namespace; + private PrintStream printStream; + private Pattern filter; + + protected Builder withClient(final KubernetesClient client) { + this.client = client; + return this; + } + + protected Builder inNamespace(final String namespace) { + this.namespace = namespace; + return this; + } + + protected Builder outputTo(final PrintStream printStream) { + this.printStream = printStream; + return this; + } + + protected Builder filter(final Pattern filter) { + this.filter = filter; + return this; + } + + protected PodLogsWatcher build() { + if (client == null) { + throw new IllegalStateException("KubernetesClient must be specified!"); + } + if (namespace == null) { + throw new IllegalStateException("Namespace must be specified!"); + } + if (printStream == null) { + throw new IllegalStateException("PrintStream must be specified!"); + } + + return new PodLogsWatcher(client, namespace, printStream, filter); + } + } +} diff --git a/core/src/test/java/cz/xtf/core/service/logs/streaming/AnnotationBasedServiceLogsConfigurationTest.java b/core/src/test/java/cz/xtf/core/service/logs/streaming/AnnotationBasedServiceLogsConfigurationTest.java new file mode 100644 index 00000000..c12c4148 --- /dev/null +++ b/core/src/test/java/cz/xtf/core/service/logs/streaming/AnnotationBasedServiceLogsConfigurationTest.java @@ -0,0 +1,70 @@ +package cz.xtf.core.service.logs.streaming; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** + * Test cases that verify the SLS configuration and usage through annotation, i.e. + * {@link ServiceLogsStreaming} + */ +public class AnnotationBasedServiceLogsConfigurationTest { + + private static final AnnotationBasedServiceLogsConfigurations CONFIGURATIONS = new AnnotationBasedServiceLogsConfigurations(); + + @ServiceLogsStreaming(filter = ".*all-colors", output = "/home/myuser/sls.log") + static class ColorsTestClass { + ; // we don't need anything in here + } + + @ServiceLogsStreaming + static class AnimalsTestClass { + ; // we don't need anything in here + } + + @ServiceLogsStreaming + static class CarsTestClass { + ; // we don't need anything in here + } + + /** + * Check that the expected configuration exists, based on the test class {@link ServiceLogsStreaming} annotation. + */ + @Test + public void testCorrectSlsConfigurationsAreCreated() { + // ColorsTestClass - check by retrieving the one based on per-class criteria + ServiceLogsConfigurationTestHelper.verifyPerClassConfigurationSearchExists( + CONFIGURATIONS.forClass(ColorsTestClass.class), ColorsTestClass.class); + // AnnotationBasedServiceLogsConfigurationTest - check by retrieving the one based on per-class criteria (negative) + ServiceLogsConfigurationTestHelper.verifyPerClassConfigurationSearchDoesNotExist( + CONFIGURATIONS.forClass(AnnotationBasedServiceLogsConfigurationTest.class), + AnnotationBasedServiceLogsConfigurationTest.class); + // AnimalsTestClass - check by retrieving the one based on per-class criteria + ServiceLogsConfigurationTestHelper.verifyPerClassConfigurationSearchExists( + CONFIGURATIONS.forClass(AnimalsTestClass.class), AnimalsTestClass.class); + // CarsTestClass - check by retrieving the one based on per-class criteria + ServiceLogsConfigurationTestHelper.verifyPerClassConfigurationSearchExists( + CONFIGURATIONS.forClass(CarsTestClass.class), CarsTestClass.class); + } + + /** + * Check that SLS configuration attributes are properly mapped when using the {@link ServiceLogsStreaming} + * annotation as a configuration source. + */ + @Test + public void testSlsConfigurationAttributesAreProperlyMapped() { + // TestClassA + ServiceLogsSettings configuration = CONFIGURATIONS.forClass(ColorsTestClass.class); + Assertions.assertEquals(ColorsTestClass.class.getName(), configuration.getTarget(), + String.format( + "SLS configuration \"target\" attribute (%s) hasn't the expected value (%s)", + configuration.getTarget(), ColorsTestClass.class.getName())); + Assertions.assertEquals(".*all-colors", configuration.getFilter(), + String.format( + "SLS configuration \"filter\" attribute (%s) hasn't the expected value (%s)", + configuration.getFilter(), ".*all-colors")); + Assertions.assertEquals("/home/myuser/sls.log", configuration.getOutputPath(), + String.format( + "SLS configuration \"output\" attribute (%s) hasn't the expected value (%s)", + configuration.getOutputPath(), "/home/myuser/sls.log")); + } +} diff --git a/core/src/test/java/cz/xtf/core/service/logs/streaming/ServiceLogColoredPrintStreamTest.java b/core/src/test/java/cz/xtf/core/service/logs/streaming/ServiceLogColoredPrintStreamTest.java new file mode 100644 index 00000000..dbeef8bc --- /dev/null +++ b/core/src/test/java/cz/xtf/core/service/logs/streaming/ServiceLogColoredPrintStreamTest.java @@ -0,0 +1,50 @@ +package cz.xtf.core.service.logs.streaming; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.List; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class ServiceLogColoredPrintStreamTest { + /** + * We test that the routine that identifies new lines in the data to print, works correctly + */ + @Test + public void testGetTokens() throws IOException { + ServiceLogColoredPrintStream coloredPrintStream = new ServiceLogColoredPrintStream.Builder() + .outputTo(System.out) + .withColor(ServiceLogColor.getNext()) + .withPrefix("TEST") + .build(); + // 7 tokens + byte[] buf = "\nAAAA\nBBBB\nCCCC\n".getBytes(StandardCharsets.UTF_8); + List tokens = coloredPrintStream.getTokens(buf, 0, buf.length); + Assertions.assertEquals(tokens.size(), 7); + // offset other than 0 + buf = "\nAAAA\nBBBB\nCCCC\n".getBytes(StandardCharsets.UTF_8); + tokens = coloredPrintStream.getTokens(buf, 1, buf.length - 1); + Assertions.assertEquals(tokens.size(), 6); + // offset set to second token, length set to second-last token + buf = "\nAAAA\nBBBB\nCCCC\n".getBytes(StandardCharsets.UTF_8); + tokens = coloredPrintStream.getTokens(buf, 1, buf.length - 2); + Assertions.assertEquals(tokens.size(), 5); + // offset set to third token, length set to second-last token + buf = "\nAAAA\nBBBB\nCCCC\n".getBytes(StandardCharsets.UTF_8); + tokens = coloredPrintStream.getTokens(buf, 5, buf.length - 7); + Assertions.assertEquals(tokens.size(), 4); + // just one non new-line token + buf = "AAAA".getBytes(StandardCharsets.UTF_8); + tokens = coloredPrintStream.getTokens(buf, 0, buf.length); + Assertions.assertEquals(tokens.size(), 1); + // just one new-line token + buf = "\n".getBytes(StandardCharsets.UTF_8); + tokens = coloredPrintStream.getTokens(buf, 0, buf.length); + Assertions.assertEquals(tokens.size(), 1); + // two new-line tokens + buf = "\n\n".getBytes(StandardCharsets.UTF_8); + tokens = coloredPrintStream.getTokens(buf, 0, buf.length); + Assertions.assertEquals(tokens.size(), 2); + } +} diff --git a/core/src/test/java/cz/xtf/core/service/logs/streaming/ServiceLogsConfigurationTestHelper.java b/core/src/test/java/cz/xtf/core/service/logs/streaming/ServiceLogsConfigurationTestHelper.java new file mode 100644 index 00000000..776272cc --- /dev/null +++ b/core/src/test/java/cz/xtf/core/service/logs/streaming/ServiceLogsConfigurationTestHelper.java @@ -0,0 +1,20 @@ +package cz.xtf.core.service.logs.streaming; + +import org.junit.jupiter.api.Assertions; + +/** + * A simple helper class that performs repetitive tasks on behalf of test methods in the context of the + * Service Logs Streaming feature testing. + */ +public class ServiceLogsConfigurationTestHelper { + public static void verifyPerClassConfigurationSearchDoesNotExist(ServiceLogsSettings configuration, + Class testClazz) { + Assertions.assertNull(configuration, + String.format("The per-class SLS configuration for \"%s\" exists", testClazz.getSimpleName())); + } + + public static void verifyPerClassConfigurationSearchExists(ServiceLogsSettings configuration, Class testClazz) { + Assertions.assertNotNull(configuration, + String.format("The per-class SLS configuration for \"%s\" is null", testClazz.getSimpleName())); + } +} diff --git a/core/src/test/java/cz/xtf/core/service/logs/streaming/SystemPropertyBasedServiceLogsConfigurationTest.java b/core/src/test/java/cz/xtf/core/service/logs/streaming/SystemPropertyBasedServiceLogsConfigurationTest.java new file mode 100644 index 00000000..36706a30 --- /dev/null +++ b/core/src/test/java/cz/xtf/core/service/logs/streaming/SystemPropertyBasedServiceLogsConfigurationTest.java @@ -0,0 +1,199 @@ +package cz.xtf.core.service.logs.streaming; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** + * Test cases to verify the SLS configuration and usage through system property, i.e. {@code xtf.log.streaming.config} + */ +public class SystemPropertyBasedServiceLogsConfigurationTest { + + static class TestClassA { + ; // we don't need anything in here + } + + static class TestClassAChild { + ; // we don't need anything in here + } + + static class TestClassB { + ; // we don't need anything in here + } + + static class TestClassBBKing { + ; // we don't need anything in here + } + + public static void verifyOneConfigurationIsCreatedForAGivenTarget( + SystemPropertyBasedServiceLogsConfigurations configurations, + String targetValue) { + final Integer expectedTargeting = 1; + List configurationsTargeting = configurations.list().stream() + .filter(c -> targetValue.equals(c.getTarget())) + .collect(Collectors.toList()); + Assertions.assertEquals(expectedTargeting, configurationsTargeting.size(), + String.format( + "The number of SLS configurations loaded at runtime (%d) and targeting \"%s\" does not equal the expected number of property based SLS configurations (%d)", + configurationsTargeting.size(), targetValue, expectedTargeting)); + } + + public static void verifyExactNumberOfConfigurationsCreated(SystemPropertyBasedServiceLogsConfigurations configurations, + int expected) { + final Integer actual = configurations.list().size(); + Assertions.assertEquals(expected, actual, + String.format( + "The number of SLS configurations loaded at runtime (%d) does not equal the expected number of property based SLS configurations (%d)", + actual, expected)); + } + + /** + * Check that the correct number of configurations are created, and also that the expected configurations exists, + * based on the system property value. + */ + @Test + public void testCorrectSlsConfigurationNumberIsCreated() { + final SystemPropertyBasedServiceLogsConfigurations configurations = new SystemPropertyBasedServiceLogsConfigurations( + "target=.*TestClassA,target=.*TestClassB.*;filter=.*"); + // let's check we have the exact number of SLS configs that we expect from the property we provided + verifyExactNumberOfConfigurationsCreated(configurations, 2); + // TestClassA - check by filtering all SLS configurations + verifyOneConfigurationIsCreatedForAGivenTarget(configurations, ".*TestClassA"); + // TestClassA - check by retrieving the one based on per-class criteria + ServiceLogsConfigurationTestHelper.verifyPerClassConfigurationSearchExists( + configurations.forClass(TestClassA.class), TestClassA.class); + // TestClassAChild - check by retrieving the one based on per-class criteria (negative) + ServiceLogsConfigurationTestHelper.verifyPerClassConfigurationSearchDoesNotExist( + configurations.forClass(TestClassAChild.class), TestClassAChild.class); + // TestClassB - check by filtering all SLS configurations + verifyOneConfigurationIsCreatedForAGivenTarget(configurations, ".*TestClassB.*"); + // TestClassB - check by retrieving the one based on per-class criteria + ServiceLogsConfigurationTestHelper.verifyPerClassConfigurationSearchExists( + configurations.forClass(TestClassB.class), TestClassB.class); + // TestClassBBKing - check by retrieving the one based on per-class criteria + ServiceLogsConfigurationTestHelper.verifyPerClassConfigurationSearchExists( + configurations.forClass(TestClassBBKing.class), TestClassBBKing.class); + } + + /** + * Check that SLS configuration attributes are properly mapped when using the system property as a configuration + * source. + */ + @Test + public void testSlsConfigurationAttributesAreProperlyMapped() { + final SystemPropertyBasedServiceLogsConfigurations configurations = new SystemPropertyBasedServiceLogsConfigurations( + "target=.*TestClassA,target=.*TestClassB.*;filter=.*"); + // TestClassA + ServiceLogsSettings configurationForTestClass = configurations.forClass(TestClassA.class); + Assertions.assertEquals(".*TestClassA", configurationForTestClass.getTarget(), + String.format( + "SLS configuration \"target\" attribute (%s) hasn't the expected value (%s)", + configurationForTestClass.getTarget(), "TestClassA")); + Assertions.assertEquals(ServiceLogsSettings.UNASSIGNED, configurationForTestClass.getFilter(), + String.format( + "SLS configuration \"filter\" attribute (%s) hasn't the expected value (%s)", + configurationForTestClass.getFilter(), ServiceLogsSettings.UNASSIGNED)); + Assertions.assertEquals(ServiceLogsSettings.UNASSIGNED, configurationForTestClass.getOutputPath(), + String.format( + "SLS configuration \"output\" attribute (%s) hasn't the expected value (%s)", + configurationForTestClass.getOutputPath(), ServiceLogsSettings.UNASSIGNED)); + // TestClassB + configurationForTestClass = configurations.forClass(TestClassB.class); + Assertions.assertEquals(".*TestClassB.*", configurationForTestClass.getTarget(), + String.format( + "SLS configuration \"target\" attribute (%s) hasn't the expected value (%s)", + configurationForTestClass.getTarget(), "TestClassB.*")); + Assertions.assertEquals(".*", configurationForTestClass.getFilter(), + String.format( + "SLS configuration \"filter\" attribute (%s) hasn't the expected value (%s)", + configurationForTestClass.getFilter(), ".*")); + Assertions.assertEquals(ServiceLogsSettings.UNASSIGNED, configurationForTestClass.getOutputPath(), + String.format( + "SLS configuration \"output\" attribute (%s) hasn't the expected value (%s)", + configurationForTestClass.getOutputPath(), ServiceLogsSettings.UNASSIGNED)); + } + + /** + * Check that a SLS configuration which has a non-valid attribute name is rejected + */ + @Test + public void testWrongAttributeNameSlsConfigurationIsRejected() { + Assertions.assertThrows(IllegalArgumentException.class, () -> new SystemPropertyBasedServiceLogsConfigurations( + "BROKENtarget=.*TestClassA,target=.*TestClassB.*;filter=.*")); + } + + /** + * Check that a SLS configuration which has a non-valid attributes format is rejected + */ + @Test + public void testWrongAttributeNameValueSeparatorSlsConfigurationIsRejected() { + Assertions.assertThrows(IllegalArgumentException.class, () -> new SystemPropertyBasedServiceLogsConfigurations( + "target:.*TestClassA")); + } + + /** + * Check that a SLS configuration which has a non-valid comma-separated format is rejected + */ + @Test + public void testBrokenItemListSlsConfigurationIsRejected() { + Assertions.assertThrows(IllegalArgumentException.class, () -> new SystemPropertyBasedServiceLogsConfigurations( + ",target=.*TestClassB.*;filter=.*")); + } + + /** + * Check that a SLS configuration which has a {@code output} attribute set to a *Nix file path is accepted + */ + @Test + public void testNixPathsInAttributesAreAccepted() { + final String filePath = "/home/myuser/sls.log"; + final String property = "target=.*TestClassB.*;output=" + filePath; + final SystemPropertyBasedServiceLogsConfigurations configurations = new SystemPropertyBasedServiceLogsConfigurations( + property); + ServiceLogsSettings configurationForTestClass = configurations.forClass(TestClassB.class); + Assertions.assertEquals(filePath, configurationForTestClass.getOutputPath(), + String.format( + "SLS configuration \"output\" attribute (%s) hasn't the expected value (%s)", + configurationForTestClass.getOutputPath(), filePath)); + } + + /** + * Check that a SLS configuration which has a {@code output} attribute set to a Windows-like file path is + * accepted + */ + @Test + public void testWinPathsInAttributesAreAccepted() { + final String filePath = "C:\\home\\myuser\\sls.log"; + final String property = "target=.*TestClassB.*;output=" + filePath; + final SystemPropertyBasedServiceLogsConfigurations configurations = new SystemPropertyBasedServiceLogsConfigurations( + property); + ServiceLogsSettings configurationForTestClass = configurations.forClass(TestClassB.class); + Assertions.assertEquals(filePath, configurationForTestClass.getOutputPath(), + String.format( + "SLS configuration \"output\" attribute (%s) hasn't the expected value (%s)", + configurationForTestClass.getOutputPath(), filePath)); + } + + /** + * Check that a SLS configuration which has a {@code target} attribute set to a regex is + * accepted + */ + @Test + public void testRegExInAttributesIsAccepted() { + final String regexText = "^\\s(?:[a|b]+)(\\d+)1{1}.*$)"; + final String property = "target=" + regexText; + final SystemPropertyBasedServiceLogsConfigurations configurations = new SystemPropertyBasedServiceLogsConfigurations( + property); + Optional configurationSearch = configurations.list().stream().findAny(); + Assertions.assertTrue(configurationSearch.isPresent(), + String.format( + "No SLS configuration has been created for the given property value (%s)", property)); + ServiceLogsSettings configuration = configurationSearch.get(); + Assertions.assertEquals(regexText, configuration.getTarget(), + String.format( + "SLS configuration \"target\" attribute (%s) hasn't the expected value (%s)", + configuration.getTarget(), regexText)); + } +} diff --git a/junit5/README.md b/junit5/README.md index 28cceae1..0f4e0a60 100644 --- a/junit5/README.md +++ b/junit5/README.md @@ -16,41 +16,81 @@ cz.xtf.junit5.listeners.TestExecutionLogger cz.xtf.junit5.listeners.TestResultReporter ``` -##### ConfigRecorder +### ConfigRecorder Records specified images url. Set `xtf.junit.used_image` property with images id split by ',' character. Particular image url will be then stored in `used-images.properties` file in project root. -##### ProjectCreator +### ProjectCreator Creates project before test executions on OpenShift if doesn't exist. Use `xtf.junit.clean_openshift` property to delete after all test have been executed. It also checks property `xtf.openshift.pullsecret` to create pull secret in the new project. -##### TestExecutionLogger +### TestExecutionLogger Logs individual test executions into the console. -##### TestExecutionReporter +### TestExecutionReporter Reports test execution on the fly into one `log/junit-report.xml` file. ## Extensions -Extensions enable better test management. Usable directly on classes and methods. +Extensions enable better test management. -##### @OpenShiftRecorder +### Usable directly on classes and methods through the related annotations. + +#### @OpenShiftRecorder Record OpenShift state when a test throws an exception or use `xtf.record.always` to record on success. Specify app names (which will be turned into regexes) to filter resources by name. When not specified, everything in test and build namespace will be recorded (regex - `.*`). Use `xtf.record.dir` to set the directory. -##### @CleanBeforeAll/@CleanBeforeEach +#### @CleanBeforeAll/@CleanBeforeEach Cleans namespace specified by `xtf.openshift.namespace` property. Either before all tests or each test execution. -##### @SinceVersion +#### @SinceVersion Marks that test for particular feature is available from specfied tag. Compares specified image version tag for particular image repo with one that is expected. Executed if tag is at least one that is expected. `@SinceVersion(image = imageId, name = imageRepo, since = "1.5")` -##### @SkipFor +#### @SkipFor Skips test if image repo matches the name in annotation. `@SkipFor(image = imageId, name = imageRepo)` -##### @KnownIssue +#### @KnownIssue Marks test as skipped rather then failed in case that thrown exception's message contains failureIdentification String. `@SkipFor(value = failureIdentification)` +### Usable through service registration + +#### ServiceLogsRunner +This extension can be registered by projects which depend on XTF, for instance by creating a file named +`org.junit.jupiter.api.extension.Extension` with the following content into the classpath: + +```text +cz.xtf.junit5.extensions.ServiceLogsStreamingRunner +``` +will allow for the extension to be loaded at runtime. The Service Logs Streaming feature can then be enabled either +through system properties or by annotating test classes with the `@ServiceLogs` annotation. + +##### Enabling via system properties +* `xtf.log.streaming.enabled` - set this property to true in order to enable the feature for all test classes +* `xtf.log.streaming.config` - set this property to define a comma separated list of valid _configuration items_ that will be +used to provide multiple SLS configuration options, in order to address different test classes in a specific way, e.g.: + + `xtf.log.streaming.config="target=TestClassA,target=TestClassB.*;output=/home/myuser/sls-logs;filter=.*my-app.*"` + +where two different _configuration items_ are listed and can be fully described as follows: + +1. _Applying only to "TestClassA", will default to System.out for `output`, while the `filter` regex is set to `.*`_ + +2. _Applying to all test classes that match "TestClassB.*", streaming logs to files in the `home/myuser/sls-logs` + directory and monitoring all the resources which name matches the `.*my-app.*` regex_ + +##### Enabling via annotation +Annotate any test class with the `@ServiceLogs` annotation in order to enable the feature for a given test class. + +##### Implementation details +Since both the above mentioned ways to enable the feature can coexist, the extension will collect configurations +separately, i.e. _annotation based_ and _property based_ configurations. +In case one given test class name matches one of the collected configurations' `target` attribute, then the remaining +configuration attributes (e.g.: `filter`) are used to start a `ServiceLogs` instance for the scenario that is covered +by the test class. +In case two configurations exist which are matched by one test class name, then the _property based configuration_ is +used. + ## Other Former xPaaS version supported test filtering based on annotations and regular expressions. Use JUnit5 tags and groups for former case and `maven-surefire` version 2.22 plugin for later case. \ No newline at end of file diff --git a/junit5/src/main/java/cz/xtf/junit5/extensions/ServiceLogsStreamingRunner.java b/junit5/src/main/java/cz/xtf/junit5/extensions/ServiceLogsStreamingRunner.java new file mode 100644 index 00000000..7fd7f59e --- /dev/null +++ b/junit5/src/main/java/cz/xtf/junit5/extensions/ServiceLogsStreamingRunner.java @@ -0,0 +1,304 @@ +package cz.xtf.junit5.extensions; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.io.PrintStream; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.extension.AfterAllCallback; +import org.junit.jupiter.api.extension.BeforeAllCallback; +import org.junit.jupiter.api.extension.ExtensionContext; + +import cz.xtf.core.bm.BuildManagers; +import cz.xtf.core.config.XTFConfig; +import cz.xtf.core.openshift.OpenShift; +import cz.xtf.core.openshift.OpenShifts; +import cz.xtf.core.service.logs.streaming.AnnotationBasedServiceLogsConfigurations; +import cz.xtf.core.service.logs.streaming.ServiceLogs; +import cz.xtf.core.service.logs.streaming.ServiceLogsSettings; +import cz.xtf.core.service.logs.streaming.SystemPropertyBasedServiceLogsConfigurations; +import cz.xtf.core.service.logs.streaming.k8s.PodLogs; +import lombok.extern.slf4j.Slf4j; + +/** + * Implements {@link BeforeAllCallback} in order to provide the logic to retrieve the Service Logs Streaming + * configurations and activating concrete {@link ServiceLogs} instances. + * + * Workflow:
+ *
    + *
  • Check whether the {@link ServiceLogsStreamingRunner#SERVICE_LOGS_STREAMING_PROPERTY_ENABLED} + * is set and apply a configuration that will enable Service Logs Streaming for all the test classes in such a + * case
  • + *
  • If {@link ServiceLogsStreamingRunner#SERVICE_LOGS_STREAMING_PROPERTY_ENABLED} is not set, then check + * whether the {@link ServiceLogsStreamingRunner#SERVICE_LOGS_STREAMING_PROPERTY_CONFIG} is set
  • + *
  • If the {@link ServiceLogsStreamingRunner#SERVICE_LOGS_STREAMING_PROPERTY_CONFIG} is set, create an + * {@link SystemPropertyBasedServiceLogsConfigurations} instance for the property value and store it for the + * current execution context
  • + *
  • If the {@link ServiceLogsStreamingRunner#SERVICE_LOGS_STREAMING_PROPERTY_CONFIG} is not set as well, create a + * {@link AnnotationBasedServiceLogsConfigurations} and store it for the current execution context
  • + *
  • Extract a unique {@link ServiceLogsSettings} instance out of the relevant Service Logs Streaming + * configurations, for it to be used by the current execution context test class
  • + *
  • Create a {@link PodLogs} instance that implements the {@link ServiceLogs} interface for a k8s based + * Cloud environment
  • < + *
  • Call {@link ServiceLogs#start()} to start the {@link PodLogs} for the current execution context test + * class
  • + *
+ */ +@Slf4j +public class ServiceLogsStreamingRunner implements BeforeAllCallback, AfterAllCallback { + private static final OpenShift openShift = OpenShifts.master(); + private static final ExtensionContext.Namespace NAMESPACE = ExtensionContext.Namespace.create("cz", "xtf", "junit", + "extensions", "ServiceLogsRunner"); + + private static final String SERVICE_LOGS_PROPERTY_BASED_CONFIGURATIONS = "SERVICE_LOGS:PROPERTY_BASED_CONFIGURATIONS"; + private static final String SERVICE_LOGS_ANNOTATION_BASED_CONFIGURATIONS = "SERVICE_LOGS:ANNOTATION_BASED_CONFIGURATIONS"; + private static final String SERVICE_LOGS_OUTPUT_STREAMS = "SERVICE_LOGS:OUTPUT_STREAMS"; + private static final String SERVICE_LOGS = "SERVICE_LOGS"; + + // Service Logs Streaming (SLS) + private static final String SERVICE_LOGS_STREAMING_PROPERTY_CONFIG = "xtf.log.streaming.config"; + private static final String SERVICE_LOGS_STREAMING_PROPERTY_ENABLED = "xtf.log.streaming.enabled"; + + @Override + public void beforeAll(ExtensionContext context) { + // if the xtf.log.streaming.enabled property is set, then we'll enable SLS for all tests + if (getServiceLogsStreamingEnabledPropertyValue()) { + enableServiceLogsStreamingForAllTests(context); + } + startServiceLogsStreaming(context); + } + + @Override + public void afterAll(ExtensionContext context) { + stopServiceLogsStreaming(context); + } + + /** + * Set the config for starting Service Logs Streaming for all tests + * + * @param extensionContext The current test execution context + */ + private void enableServiceLogsStreamingForAllTests(ExtensionContext extensionContext) { + ExtensionContext.Store store = extensionContext.getStore(NAMESPACE); + String key = SERVICE_LOGS_PROPERTY_BASED_CONFIGURATIONS; + SystemPropertyBasedServiceLogsConfigurations systemPropertyBasedServiceLogsConfigurations = store.get(key, + SystemPropertyBasedServiceLogsConfigurations.class); + if (systemPropertyBasedServiceLogsConfigurations != null) { + store.remove(key); + } + store.put(key, new SystemPropertyBasedServiceLogsConfigurations("target=.*")); + } + + /** + * Access the execution context system property based Service Logs Streaming configurations + * + * @param extensionContext The current test execution context + * @return An instance of {@link SystemPropertyBasedServiceLogsConfigurations}, representing all the + * system property based Service Logs Streaming configurations + */ + private SystemPropertyBasedServiceLogsConfigurations systemPropertyBasedServiceLogsConfigurations( + ExtensionContext extensionContext) { + ExtensionContext.Store store = extensionContext.getStore(NAMESPACE); + String key = SERVICE_LOGS_PROPERTY_BASED_CONFIGURATIONS; + SystemPropertyBasedServiceLogsConfigurations systemPropertyBasedServiceLogsConfigurations = store.get(key, + SystemPropertyBasedServiceLogsConfigurations.class); + if (systemPropertyBasedServiceLogsConfigurations == null) { + final String serviceLogsStreamingConfig = getServiceLogsStreamingConfigPropertyValue(); + // if the property is not set, then there will be no configurations at all + if ((serviceLogsStreamingConfig != null) + && (!serviceLogsStreamingConfig.isEmpty())) { + systemPropertyBasedServiceLogsConfigurations = new SystemPropertyBasedServiceLogsConfigurations( + serviceLogsStreamingConfig); + store.put(key, systemPropertyBasedServiceLogsConfigurations); + } + } + return systemPropertyBasedServiceLogsConfigurations; + } + + /** + * Access the execution context annotation based Service Logs Streaming configurations + * + * @param extensionContext The current test execution context + * @return An instance of {@link AnnotationBasedServiceLogsConfigurations}, representing all the + * annotation based Service Logs Streaming configurations + */ + private AnnotationBasedServiceLogsConfigurations annotationBasedServiceLogsConfigurations( + ExtensionContext extensionContext) { + ExtensionContext.Store store = extensionContext.getStore(NAMESPACE); + String key = SERVICE_LOGS_ANNOTATION_BASED_CONFIGURATIONS; + AnnotationBasedServiceLogsConfigurations annotationBasedServiceLogsConfigurations = store.get(key, + AnnotationBasedServiceLogsConfigurations.class); + if (annotationBasedServiceLogsConfigurations == null) { + annotationBasedServiceLogsConfigurations = new AnnotationBasedServiceLogsConfigurations(); + store.put(key, annotationBasedServiceLogsConfigurations); + } + return annotationBasedServiceLogsConfigurations; + } + + /** + * Access the execution context test classes {@link ServiceLogs} related instances + * + * @param extensionContext The current test execution context + * @return A {@link Map} containing test classes keys and related {@link ServiceLogs} instances + */ + private Map serviceLogs(ExtensionContext extensionContext) { + ExtensionContext.Store store = extensionContext.getStore(NAMESPACE); + String key = SERVICE_LOGS; + Map serviceLogsMap = store.get(key, Map.class); + if (serviceLogsMap == null) { + serviceLogsMap = new HashMap<>(); + store.put(key, serviceLogsMap); + } + return serviceLogsMap; + } + + /** + * Access the execution context {@link OutputStream} instances that represent the output of existing + * {@link ServiceLogs} instances + * + * @param extensionContext The current test execution context + * @return A list of {@link OutputStream} instances that represent the output of existing {@link ServiceLogs} + * instances + */ + private List serviceLogsOutputStreams(ExtensionContext extensionContext) { + ExtensionContext.Store store = extensionContext.getStore(NAMESPACE); + String key = SERVICE_LOGS_OUTPUT_STREAMS; + List serviceLogsOutputStreams = store.get(key, List.class); + if (serviceLogsOutputStreams == null) { + serviceLogsOutputStreams = new ArrayList<>(); + store.put(key, serviceLogsOutputStreams); + } + return serviceLogsOutputStreams; + } + + /** + * Read the {@link ServiceLogsStreamingRunner#SERVICE_LOGS_STREAMING_PROPERTY_CONFIG} property value + * + * @return A string representing the Service Logs Streaming configuration, i.e. a list of configuration + * items, each one storing attributes and their values + */ + private String getServiceLogsStreamingConfigPropertyValue() { + return XTFConfig.get(SERVICE_LOGS_STREAMING_PROPERTY_CONFIG, ""); + } + + /** + * Read the {@link ServiceLogsStreamingRunner#SERVICE_LOGS_STREAMING_PROPERTY_ENABLED} property value + * + * @return True, if the property value equals "true" (case insensitive), false otherwise + */ + private boolean getServiceLogsStreamingEnabledPropertyValue() { + return Boolean.parseBoolean(XTFConfig.get(SERVICE_LOGS_STREAMING_PROPERTY_ENABLED, "false")); + } + + /** + * Start Service Logs Streaming after creating a concrete {@link ServiceLogs} instance based on current + * configuration settings + * + * @param extensionContext The current test execution context + */ + private void startServiceLogsStreaming(ExtensionContext extensionContext) { + ServiceLogsSettings currentClazzServiceLogsSettings = retrieveServiceLogsSettings(extensionContext); + if (currentClazzServiceLogsSettings != null) { + // start the service logs, which is lazily initialized and added to the extension context store + ServiceLogs serviceLogs = createServiceLogs(extensionContext, currentClazzServiceLogsSettings); + serviceLogs(extensionContext).put(extensionContext.getRequiredTestClass().getName(), serviceLogs); + serviceLogs.start(); + } + } + + /** + * Look for a valid system property based configuration for the given execution context, or search for an + * annotation based one if it doesn't exist. + * + * @param extensionContext The current test execution context + * @return A {@link ServiceLogsSettings} instance representing the actual configuration for a {@link ServiceLogs} + * instance to be created for a given execution context + */ + private ServiceLogsSettings retrieveServiceLogsSettings(ExtensionContext extensionContext) { + if (systemPropertyBasedServiceLogsConfigurations(extensionContext) != null) { + ServiceLogsSettings currentClazzServiceLogsSettings = systemPropertyBasedServiceLogsConfigurations(extensionContext) + .forClass( + extensionContext.getRequiredTestClass()); + if (currentClazzServiceLogsSettings != null) { + return currentClazzServiceLogsSettings; + } + } + return annotationBasedServiceLogsConfigurations(extensionContext).forClass( + extensionContext.getRequiredTestClass()); + } + + /** + * Creates a concrete {@link ServiceLogs} instance to manage service logs streaming for the current environment + * + * @param extensionContext The current test execution context + * @param testClazzServiceLogsSettings The settings to be used in order to create a concrete {@link ServiceLogs} + * instance for a given test class + * @return A concrete {@link ServiceLogs} instance for a given test class + */ + private ServiceLogs createServiceLogs(ExtensionContext extensionContext, + ServiceLogsSettings testClazzServiceLogsSettings) { + log.debug("createServiceLogs"); + ServiceLogs serviceLogs; + + // namespaces + final String masterNamespace = openShift.getNamespace(); + final String buildsNamespace = BuildManagers.get().openShift().getNamespace(); + final List uniqueNamespaces = new ArrayList<>(); + uniqueNamespaces.add(masterNamespace); + if (!masterNamespace.equals(buildsNamespace)) { + uniqueNamespaces.add(buildsNamespace); + } + // output defaults to System.out + PrintStream out = System.out; + if (!ServiceLogsSettings.UNASSIGNED.equals(testClazzServiceLogsSettings.getOutputPath())) { + try { + // in case there's output stream to be used, let's use it and keep track of it, + // so that it can be closed eventually + final File baseOutputPath = new File(testClazzServiceLogsSettings.getOutputPath()); + if (!baseOutputPath.exists() && !baseOutputPath.mkdirs()) { + throw new IllegalStateException( + "Cannot create SLS base output path: " + testClazzServiceLogsSettings.getOutputPath()); + } + File outputFile = new File(testClazzServiceLogsSettings.getOutputPath(), + extensionContext.getRequiredTestClass().getName()); + out = new PrintStream(new FileOutputStream(outputFile)); + serviceLogsOutputStreams(extensionContext).add(out); + } catch (FileNotFoundException e) { + log.warn("Could not create file stream {} because of the following error: {}. Redirecting to System.out", + testClazzServiceLogsSettings.getOutputPath(), e.getMessage()); + } + } + serviceLogs = new PodLogs.Builder() + .withClient(openShift) + .inNamespaces(uniqueNamespaces) + // The life cycle of the PrintStream instance that is being passed on to ServiceLogs + // must be handled in the outer scope, which is not (usually) here. + .outputTo(out) + .filter(ServiceLogsSettings.UNASSIGNED.equals(testClazzServiceLogsSettings.getFilter()) ? null + : testClazzServiceLogsSettings.getFilter()) + .build(); + + return serviceLogs; + } + + private void stopServiceLogsStreaming(ExtensionContext extensionContext) { + // stop all service logs + for (ServiceLogs serviceLogs : serviceLogs(extensionContext).values()) { + serviceLogs.stop(); + } + // ... and any used output stream used by service logging + for (OutputStream outputStream : serviceLogsOutputStreams(extensionContext)) { + try { + outputStream.close(); + } catch (IOException e) { + log.warn("Couldn't close Print Stream due to IOException: {}", e.getMessage()); + } + } + } +}