diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..5b60d43 --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1 @@ +buildPlugin(platforms: ['linux']) diff --git a/demo/Makefile b/demo/Makefile new file mode 100644 index 0000000..e8e5a74 --- /dev/null +++ b/demo/Makefile @@ -0,0 +1,33 @@ +# Just a Makefile for manual testing +.PHONY: all + +ARTIFACT_ID = jenkins-external-task-logging-elk-demo +VERSION = 2.131-elk-SNAPSHOT +CWP_VERSION= 1.0 + +all: clean build + +clean: + rm -rf tmp + mkdir tmp + +build: tmp/output/target/${ARTIFACT_ID}-${VERSION}.war + +tmp/output/target/${ARTIFACT_ID}-${VERSION}.war: + mvn com.googlecode.maven-download-plugin:download-maven-plugin:1.4.0:artifact \ + -DgroupId=io.jenkins.tools.custom-war-packager \ + -DartifactId=custom-war-packager-cli \ + -Dclassifier=jar-with-dependencies \ + -Dversion=${CWP_VERSION} \ + -DoutputDirectory=tmp \ + -DoutputFileName=cwp-cli.jar + java -jar tmp/cwp-cli.jar \ + -configPath packager-config.yml -version ${VERSION} + +run: tmp/output/target/${ARTIFACT_ID}-${VERSION}.war + docker-compose rm -fv + docker-compose up --build --force-recreate jenkins elk + +debug: tmp/output/target/${ARTIFACT_ID}-${VERSION}.war + docker-compose rm -fv + docker-compose up --build --force-recreate jenkinsDebug elk diff --git a/demo/README.md b/demo/README.md new file mode 100644 index 0000000..6e1825a --- /dev/null +++ b/demo/README.md @@ -0,0 +1,72 @@ +External Task Logging to Elasticsearch Demo +=== + +This demo packages Jenkins WAR for External Task Logging to Elasticsearch with help of Logstash plugin. + +This demo includes [Logstash Plugin PR#18](https://github.com/jenkinsci/logstash-plugin/pull/18) and +all its upstream dependencies. +It also bundles auto-configuration System Groovy scripts, so that the WAR file starts +up with pre-configured Logstash plugin settings and some other configs. + +Features of the demo: + +* Pipeline jobs logging goes to Elasticsearch +* When tasks are executed on agents, the logs get posted to Elasticsearch directly + without passing though the master and causing scalability issues +* Pipeline jobs override standard Log actions in the Jenkins core, so the + underlying implementation is transparent to users +* Secrets are escaped in stored/displayed logs when running on master and agents. +* Console annotations work as they work for common Jenkins instances +* Log blocks are collapsible in the _Console_ screen +* Origin container ID of every message is visible in Kibana (if you have set that up) via sender field + +The demo can be run in Docker Compose, +ELK stack is provided by the [sebp/elk](https://hub.docker.com/r/sebp/elk/) image in this case. + +## Prerequisites + +* Docker and Docker Compose are installed + +## Building demo + +To build the demo... + +1. Go to the repository root, run `mvn clean package` to build Jenkins Custom WAR Packager +2. Change directory to the demo root +3. Run `make build` + +First build may take a while, because the packager will need to checkout and build +many repositories. + +## Running demo + +1. Run `make run`. It will spin up the demo with predefined environment. + Jenkins will be available on the port 8080, credentials: `admin/admin` +2. If you want to run demo jobs on the agent, +also run `docker-compose up agent` in a separate terminal window +3. In order to access the instance, use the "admin/admin" credentials. +4. Run one of the demo jobs. +5. Browse logs + * Classic Log action queries data from Elasticsearch + * There is a _Log (Kibana)_ action in runs, which shows Kibana. + * In order to see Kibana logs, you will need to configure the default index in the + embedded page once Jenkins starts up. Use `logstash/` as a default index and + `@timestamp` as data source + +## Manual run + +This guideline allows to run the demo locally. +Only Logstash will be preconfigured. + +1. Run `docker run -p 5601:5601 -p 9200:9200 -p 5044:5044 -it --name elk sebp/elk:es241_l240_k461` +to start the Docker container to to expose ports +2. Run Jenkins using `JENKINS_HOME=$(pwd)/work java -jar tmp/output/target/external-task-logging-elk-2.107.3-elk-SNAPSHOT.war --httpPort=8080 --prefix=/jenkins` +(or just `run run.sh`). + * If needed, the demo can be configured by setting system properties + * `elasticsearch.host` - host, defaults to `http://elk` + * `elasticsearch.port` - Elasticsearch port, defaults to `9200` + * `logstash.key` - Path to the root index/key for logging, defaults to `/logstash/logs` + * `elasticsearch.username` and `elasticsearch.password` - +3. Pass through the installation Wizard +4. Create a Pipeline job with some logging (e.g. `echo` commands), run it +5. Browse logs (see above) diff --git a/demo/docker-compose.yml b/demo/docker-compose.yml new file mode 100644 index 0000000..d7e362b --- /dev/null +++ b/demo/docker-compose.yml @@ -0,0 +1,51 @@ +version: '3' + +services: + elk: + #TODO: update to ES 5.0 + image: sebp/elk:es241_l240_k461 + container_name: elk + ports: + - "5601:5601" # Kibana + - "9200:9200" # Elasticsearch + - "5044:5044" # Logstash + expose: + - "5601" + - "9200" + - "5044" + jenkins: + image: jenkins/demo-external-task-logging-elk:latest + container_name: jenkins + links: + - elk + environment: + - JAVA_OPTS=-Dio.jenkins.demo.external-task-logging-elk.enabled=true -Djenkins.install.runSetupWizard=false + ports: + - "8080:8080" + - "9000:9000" + expose: + - "8080" + - "9000" + jenkinsDebug: + image: jenkins/demo-external-task-logging-elk:latest + container_name: jenkins + links: + - elk + environment: + - JAVA_OPTS=-Dio.jenkins.demo.external-task-logging-elk.enabled=true -Djenkins.install.runSetupWizard=false + - DEBUG=true + ports: + - "8080:8080" + - "9000:9000" + - "5005:5005" + expose: + - "8080" + - "9000" + - "5005" + agent: + image: cloudbees/jnlp-slave-with-java-build-tools:2.2.0 + links: + - elk + - jenkins + # TODO there is no -noreconnect yet this does not work when first started; you need to relaunch it; work around with wait-for-it: https://docs.docker.com/compose/startup-order/ + command: -url http://jenkins:8080/ f9bf0c290371481814f8bc235e3c53736ea9cd9f11b466b76ad794b91cb57a0b -workDir "/home/jenkins" agent diff --git a/demo/external-logging-demo.iml b/demo/external-logging-demo.iml new file mode 100644 index 0000000..88258e8 --- /dev/null +++ b/demo/external-logging-demo.iml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/demo/packager-config.yml b/demo/packager-config.yml new file mode 100644 index 0000000..0b445ab --- /dev/null +++ b/demo/packager-config.yml @@ -0,0 +1,99 @@ +bundle: + groupId: "io.jenkins.tools.war-packager.demo" + artifactId: "jenkins-external-task-logging-elk-demo" + vendor: "Jenkins project" + title: "Jenkins External Task Logging demo for Elasticsearch" + description: "Jenkins External Task Logging demo for Elasticsearch, packaged as a single WAR file. It automatically configures logging on startup" +buildSettings: + docker: + base: "jenkins/jenkins:2.130" + tag: "jenkins/demo-external-task-logging-elk" + build: true +war: + groupId: "org.jenkins-ci.main" + artifactId: "jenkins-war" + source: + version: 2.131-SNAPSHOT +plugins: + - groupId: "org.jenkins-ci.plugins" + artifactId: "structs" + source: + version: 1.14 + - groupId: "org.jenkins-ci.plugins" + artifactId: "scm-api" + source: + version: 2.2.7 + - groupId: "org.jenkins-ci.plugins.workflow" + artifactId: "workflow-aggregator" + source: + version: 2.5 + - groupId: "org.jenkins-ci.plugins.workflow" + artifactId: "workflow-api" + source: + version: 2.29-rc219.239019e84015 + build: + buildOriginalVersion: true + - groupId: "org.jenkins-ci.plugins.workflow" + artifactId: "workflow-step-api" + source: + version: 2.15 + - groupId: "org.jenkins-ci.plugins.workflow" + artifactId: "workflow-support" + source: + version: 2.19-rc265.3e5e4aeecfff + build: + buildOriginalVersion: true + - groupId: "org.jenkins-ci.plugins.workflow" + artifactId: "workflow-job" + source: + version: 2.22-rc311.5616213fbed0 + build: + buildOriginalVersion: true + - groupId: "org.jenkins-ci.plugins.workflow" + artifactId: "workflow-durable-task-step" + source: + version: 2.20-rc333.74dc7c303e6d + build: + buildOriginalVersion: true + - groupId: "org.jenkins-ci.plugins" + artifactId: "logstash" + source: + version: 2.1.1-SNAPSHOT + - groupId: "io.jenkins.plugins.external-logging" + artifactId: "external-logging-api" + source: + version: 1.0-alpha-1-SNAPSHOT + - groupId: "io.jenkins.plugins.external-logging" + artifactId: "external-logging-elasticsearch" + source: + version: 1.0-alpha-1-SNAPSHOT + + # Security warnings + - groupId: "org.jenkins-ci.plugins" + artifactId: "junit" + source: + version: 1.24 + - groupId: "org.jenkins-ci.plugins" + artifactId: "mailer" + source: + version: 1.21 + - groupId: "org.jenkins-ci.plugins" + artifactId: "git-client" + source: + version: 2.7.2 + - groupId: "org.jenkins-ci.plugins" + artifactId: "credentials-binding" + source: + version: 1.16 + - groupId: "org.jenkins-ci.plugins" + artifactId: "docker-commons" + source: + version: 1.13 +systemProperties: { + jenkins.model.Jenkins.slaveAgentPort: "9000", + jenkins.model.Jenkins.slaveAgentPortEnforce: "true"} +groovyHooks: + - type: "init" + id: "initScripts" + source: + dir: src/main/groovy diff --git a/demo/pom.xml b/demo/pom.xml new file mode 100644 index 0000000..6b5b0d1 --- /dev/null +++ b/demo/pom.xml @@ -0,0 +1,88 @@ + + + 4.0.0 + + + org.jenkins-ci.plugins + plugin + 3.15 + + + + io.jenkins.plugins.external-logging + external-logging-demo + External Logging for Elasticsearch/Logstash Demo + The plugin provides API to simplify external logging implementations for Jenkins + https://wiki.jenkins.io/display/JENKINS/External+Logging+API+Plugin + ${revision}${changelist} + hpi + + + 1.0-alpha-1 + -SNAPSHOT + 2.131-SNAPSHOT + 8 + true + + + + + MIT License + https://opensource.org/licenses/MIT + + + + + scm:git:git://github.com/jenkinsci/${project.artifactId}-plugin.git + scm:git:git@github.com:jenkinsci/${project.artifactId}-plugin.git + https://github.com/jenkinsci/${project.artifactId}-plugin + ${scmTag} + + + + + io.jenkins.plugins.external-logging + external-logging-elasticsearch + 1.0-alpha-1-SNAPSHOT + + + + + + + org.codehaus.gmaven + gmaven-plugin + 1.5-jenkins-3 + + + org.codehaus.gmaven.runtime + gmaven-runtime-1.8 + 1.5-jenkins-3 + + + + + + compile + testCompile + + + + + + + + + + repo.jenkins-ci.org + https://repo.jenkins-ci.org/public/ + + + + + repo.jenkins-ci.org + https://repo.jenkins-ci.org/public/ + + + + diff --git a/demo/src/main/groovy/1_System.groovy b/demo/src/main/groovy/1_System.groovy new file mode 100644 index 0000000..8132999 --- /dev/null +++ b/demo/src/main/groovy/1_System.groovy @@ -0,0 +1,68 @@ +import hudson.security.csrf.DefaultCrumbIssuer +import hudson.model.* +import hudson.security.FullControlOnceLoggedInAuthorizationStrategy +import hudson.security.HudsonPrivateSecurityRealm +import hudson.util.Secret +import jenkins.model.Jenkins +import jenkins.model.JenkinsLocationConfiguration +import jenkins.CLI +import jenkins.security.s2m.AdminWhitelistRule +import org.kohsuke.stapler.StaplerProxy + +import com.cloudbees.plugins.credentials.CredentialsProvider +import com.cloudbees.plugins.credentials.CredentialsScope +import com.cloudbees.plugins.credentials.domains.Domain +import org.jenkinsci.plugins.plaincredentials.impl.StringCredentialsImpl + +//TODO: Migrate to JCasC once it supports disabling via system property + +if (!Boolean.getBoolean("io.jenkins.demo.external-task-logging-elk.enabled")) { + // Production mode, we do not configure the system + return +} + +println("-- System configuration") + +println("--- Installing the Security Realm") +def securityRealm = new HudsonPrivateSecurityRealm(false) +User user = securityRealm.createAccount("user", "user") +user.setFullName("User") +User admin = securityRealm.createAccount("admin", "admin") +admin.setFullName("Admin") +Jenkins.instance.setSecurityRealm(securityRealm) + +println("---Installing the demo Authorization strategy") +Jenkins.instance.authorizationStrategy = new FullControlOnceLoggedInAuthorizationStrategy() + +println("--- Configuring Remoting (JNLP4 only, no Remoting CLI)") +CLI.get().enabled = false +Jenkins.instance.agentProtocols = new HashSet(["JNLP4-connect"]) +Jenkins.instance.getExtensionList(StaplerProxy.class) + .get(AdminWhitelistRule.class) + .masterKillSwitch = false + +println("--- Checking the CSRF protection") +if (Jenkins.instance.crumbIssuer == null) { + println "CSRF protection is disabled, Enabling the default Crumb Issuer" + Jenkins.instance.crumbIssuer = new DefaultCrumbIssuer(true) +} + +println("--- Configuring Quiet Period") +// We do not wait for anything, demo should be fast +Jenkins.instance.quietPeriod = 0 + +println("--- Configuring Email global settings") +JenkinsLocationConfiguration.get().adminAddress = "admin@non.existent.email" +// Mailer.descriptor().defaultSuffix = "@non.existent.email" + +println("--- Adding test credentials") +def c = new StringCredentialsImpl( + CredentialsScope.GLOBAL, + "token", + "Test token", + Secret.fromString("SECRET_TOKEN_WHICH_SHOULD_NOD_BE_DISPLAYED") +) + +CredentialsProvider.lookupStores(Jenkins.instance).each { it -> + it.addCredentials(Domain.global(), c) +} diff --git a/demo/src/main/groovy/2_ExtLogging.groovy b/demo/src/main/groovy/2_ExtLogging.groovy new file mode 100644 index 0000000..68a8f89 --- /dev/null +++ b/demo/src/main/groovy/2_ExtLogging.groovy @@ -0,0 +1,27 @@ +import io.jenkins.plugins.extlogging.api.impl.ExternalLoggingGlobalConfiguration +import io.jenkins.plugins.extlogging.elasticsearch.ElasicsearchLoggingMethodFactory +import io.jenkins.plugins.extlogging.elasticsearch.ElasticsearchLogBrowserFactory +import io.jenkins.plugins.extlogging.elasticsearch.ElasticsearchGlobalConfiguration +import io.jenkins.plugins.extlogging.elasticsearch.ElasticsearchConfiguration + + +println("--- Configuring Logstash") +String logstashPort = System.getProperty("elasticsearch.port"); +int port = logstashPort != null ? Integer.parseInt(logstashPort) : 9200; + +def config = ElasticsearchGlobalConfiguration.get(); + +ElasticsearchConfiguration cfg = new ElasticsearchConfiguration( + System.getProperty("elasticsearch.uri", "http://elk:${port}") +) + +config.elasticsearch = cfg; +config.key = System.getProperty("elasticsearch.key", "/logstash/logs") + +//TODO: support credentials +//descriptor.@username = System.getProperty("elasticsearch.username") +//descriptor.@password = System.getProperty("elasticsearch.password") + +println("--- Configuring External Logging") +ExternalLoggingGlobalConfiguration.instance.loggingMethod = new ElasicsearchLoggingMethodFactory() +ExternalLoggingGlobalConfiguration.instance.logBrowser = new ElasticsearchLogBrowserFactory() diff --git a/demo/src/main/groovy/3_Agent.groovy b/demo/src/main/groovy/3_Agent.groovy new file mode 100644 index 0000000..ac20c95 --- /dev/null +++ b/demo/src/main/groovy/3_Agent.groovy @@ -0,0 +1,15 @@ +import hudson.slaves.DumbSlave; +import hudson.slaves.JNLPLauncher; +import jenkins.model.Jenkins; +import jenkins.slaves.JnlpSlaveAgentProtocol; + +import javax.crypto.spec.SecretKeySpec; + +println("-- Configuring the agent") + +// Hardcode secret so that Docker Compose can connect agents +JnlpSlaveAgentProtocol.SLAVE_SECRET.@key = new SecretKeySpec(new byte[10], "HmacSHA256"); + +// Register the agent +def node = new DumbSlave("agent", "/home/jenkins", new JNLPLauncher(true)); +Jenkins.instance.addNode(node); diff --git a/demo/src/main/groovy/4_Jobs.groovy b/demo/src/main/groovy/4_Jobs.groovy new file mode 100644 index 0000000..d0cc7e2 --- /dev/null +++ b/demo/src/main/groovy/4_Jobs.groovy @@ -0,0 +1,56 @@ +//TODO: Migrate to JCasC once it supports disabling via system property +import jenkins.model.Jenkins +import org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition +import org.jenkinsci.plugins.workflow.job.WorkflowJob + +if (!Boolean.getBoolean("io.jenkins.demo.external-task-logging-elk.enabled")) { + // Production mode, we do not configure the system + return +} + +println("-- Creating Jobs") +//TODO: Classes do not work here, so some copy-paste for now + +if(Jenkins.instance.getItem("Demo_master") == null) { + WorkflowJob project1 = Jenkins.instance.createProject(WorkflowJob.class, "Demo_master") + project1.definition = new CpsFlowDefinition( + "node('master') {\n" + + " sh \"ping -c 20 google.com\"\n" + + "}", + true // Sandbox + ) + project1.save() +} + +if(Jenkins.instance.getItem("Demo_agent") == null) { + WorkflowJob project2 = Jenkins.instance.createProject(WorkflowJob.class, "Demo_agent") + project2.definition = new CpsFlowDefinition( + "node('agent') {\n" + + " sh \"echo Hello, world!\"\n" + + // TODO Current demo image does not have ping, ORLY (alpine) + // " sh \"ping -c 20 google.com\"\n" + + "}", + true // Sandbox + ) + project2.save() +} + +if(Jenkins.instance.getItem("Demo_parallel") == null) { + WorkflowJob project3 = Jenkins.instance.createProject(WorkflowJob.class, "Demo_parallel") + project3.definition = new CpsFlowDefinition( + "parallel local: {\n" + + " node('master') {\n" + + " sh 'for x in 0 1 2 3 4 5 6 7 8 9; do echo \$x; sleep 1; done'\n" + + " }\n" + + "}, remote: {\n" + + " node('agent') {\n" + + " withCredentials([string(credentialsId: 'token', variable: 'TOKEN')]) {\n" + + " sh 'echo receiving \$TOKEN'\n" + + " sh 'for x in 0 1 2 3 4 5 6 7 8 9; do echo \$x; sleep 1; done'\n" + + " }\n" + + " }\n" + + "}", + true // Sandbox + ) + project3.save() +} diff --git a/demo/src/main/groovy/5_SaveToDisk.groovy b/demo/src/main/groovy/5_SaveToDisk.groovy new file mode 100644 index 0000000..0b18d6f --- /dev/null +++ b/demo/src/main/groovy/5_SaveToDisk.groovy @@ -0,0 +1,3 @@ +import jenkins.model.Jenkins + +Jenkins.instance.save() diff --git a/demo/src/main/java/Stub.java b/demo/src/main/java/Stub.java new file mode 100644 index 0000000..80dfe86 --- /dev/null +++ b/demo/src/main/java/Stub.java @@ -0,0 +1,6 @@ +public class Stub { + // Nothing, just to make the compiler happy + public void foo() { + + } +} \ No newline at end of file diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..e6617a5 --- /dev/null +++ b/pom.xml @@ -0,0 +1,129 @@ + + + 4.0.0 + + + org.jenkins-ci.plugins + plugin + 3.18 + + + + io.jenkins.plugins.external-logging + external-logging-elasticsearch + Elasticsearch External Logging plugin + The plugin implements external logging to Elasticsearch + https://wiki.jenkins.io/display/JENKINS/External+Logging+API+Plugin + ${revision}${changelist} + hpi + + + 1.0-alpha-1 + -SNAPSHOT + 2.131-SNAPSHOT + 8 + true + + + + + MIT License + https://opensource.org/licenses/MIT + + + + + scm:git:git://github.com/jenkinsci/${project.artifactId}-plugin.git + scm:git:git@github.com:jenkinsci/${project.artifactId}-plugin.git + https://github.com/jenkinsci/${project.artifactId}-plugin + ${scmTag} + + + + + io.jenkins.plugins.external-logging + external-logging-api + 1.0-alpha-1-SNAPSHOT + + + org.jenkins-ci.plugins + apache-httpcomponents-client-4-api + 4.5.3-2.1 + + + org.jenkins-ci.plugins + credentials + 2.1.16 + + + org.jenkins-ci.plugins + structs + 1.14 + + + org.jenkins-ci.plugins.workflow + workflow-cps + 2.19 + test + + + org.jenkins-ci.plugins.workflow + workflow-basic-steps + 2.2 + test + + + org.jenkins-ci.plugins.workflow + workflow-durable-task-step + 2.20-rc333.74dc7c303e6d + test + + + + + org.jenkins-ci.test + docker-fixtures + 1.7 + test + + + + + + + repo.jenkins-ci.org + https://repo.jenkins-ci.org/public/ + + + + + repo.jenkins-ci.org + https://repo.jenkins-ci.org/public/ + + + + diff --git a/src/main/java/io/jenkins/plugins/extlogging/elasticsearch/ElasicsearchLoggingMethodFactory.java b/src/main/java/io/jenkins/plugins/extlogging/elasticsearch/ElasicsearchLoggingMethodFactory.java new file mode 100644 index 0000000..703705d --- /dev/null +++ b/src/main/java/io/jenkins/plugins/extlogging/elasticsearch/ElasicsearchLoggingMethodFactory.java @@ -0,0 +1,47 @@ +package io.jenkins.plugins.extlogging.elasticsearch; + +import hudson.Extension; +import hudson.model.Run; +import io.jenkins.plugins.extlogging.api.ExternalLoggingMethod; +import io.jenkins.plugins.extlogging.api.ExternalLoggingMethodFactory; +import io.jenkins.plugins.extlogging.api.ExternalLoggingMethodFactoryDescriptor; +import jenkins.model.logging.Loggable; +import org.jenkinsci.Symbol; +import org.kohsuke.stapler.DataBoundConstructor; +import org.kohsuke.stapler.DataBoundSetter; + +import javax.annotation.CheckForNull; + +/** + * @author Oleg Nenashev + * @since TODO + */ +public class ElasicsearchLoggingMethodFactory extends ExternalLoggingMethodFactory { + + @CheckForNull + private String prefix; + + @DataBoundConstructor + public ElasicsearchLoggingMethodFactory() { + + } + + @DataBoundSetter + public void setPrefix(@CheckForNull String prefix) { + this.prefix = prefix; + } + + @Override + public ExternalLoggingMethod create(Loggable loggable) { + if (loggable instanceof Run) { + return new ElasticsearchLoggingMethod((Run) loggable, prefix); + } + return null; + } + + @Extension + @Symbol("elasticsearch") + public static final class DescriptorImpl extends ExternalLoggingMethodFactoryDescriptor { + + } +} diff --git a/src/main/java/io/jenkins/plugins/extlogging/elasticsearch/ElasticsearchConfiguration.java b/src/main/java/io/jenkins/plugins/extlogging/elasticsearch/ElasticsearchConfiguration.java new file mode 100644 index 0000000..2f058f0 --- /dev/null +++ b/src/main/java/io/jenkins/plugins/extlogging/elasticsearch/ElasticsearchConfiguration.java @@ -0,0 +1,109 @@ +package io.jenkins.plugins.extlogging.elasticsearch; + +import com.cloudbees.plugins.credentials.CredentialsProvider; +import com.cloudbees.plugins.credentials.common.UsernamePasswordCredentials; +import com.cloudbees.plugins.credentials.matchers.IdMatcher; +import hudson.AbortException; +import hudson.Extension; +import hudson.model.Describable; +import hudson.model.Descriptor; +import hudson.security.ACL; +import jenkins.model.Jenkins; +import org.jenkinsci.Symbol; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; +import org.kohsuke.stapler.DataBoundConstructor; +import org.kohsuke.stapler.DataBoundSetter; + +import javax.annotation.CheckForNull; +import javax.annotation.Nonnull; +import java.io.IOException; +import java.util.Collections; +import java.util.List; + +/** + * @author Oleg Nenashev + * @since TODO + */ +public class ElasticsearchConfiguration implements Describable { + + @Nonnull + private final String uri; + + @CheckForNull + private String credentialsId; + + @DataBoundConstructor + public ElasticsearchConfiguration(@Nonnull String uri) { + this.uri = uri; + } + + @DataBoundSetter + public void setCredentialsId(@CheckForNull String credentialsId) { + this.credentialsId = credentialsId; + } + + @Nonnull + public String getUri() { + return uri; + } + + @CheckForNull + public String getCredentialsId() { + return credentialsId; + } + + @CheckForNull + public UsernamePasswordCredentials getCredentials() { + if (credentialsId == null) { + return null; + } + return getCredentials(uri, credentialsId); + } + + @Nonnull + public UsernamePasswordCredentials getCredentialsOrFail() throws IOException { + UsernamePasswordCredentials creds = getCredentials(); + if (creds == null) { + throw new IOException("Cannot find credentials with id=" + credentialsId); + } + return creds; + } + + @CheckForNull + private static UsernamePasswordCredentials getCredentials(@Nonnull String uri, @Nonnull String id) { + //TODO: URL Filter + UsernamePasswordCredentials credential = null; + List credentials = CredentialsProvider.lookupCredentials( + UsernamePasswordCredentials.class, Jenkins.get(), ACL.SYSTEM, Collections.emptyList()); + IdMatcher matcher = new IdMatcher(id); + for (UsernamePasswordCredentials c : credentials) { + if (matcher.matches(c)) { + credential = c; + } + } + return credential; + } + + @Override + public Descriptor getDescriptor() { + return Jenkins.get().getDescriptor(ElasticsearchConfiguration.class); + } + + @Extension + @Symbol("elasticsearch") + public static class DescriptorImpl extends Descriptor { + //TODO: Connection verifucation and other common stuff + } + + @Nonnull + @Restricted(NoExternalUse.class) + public static ElasticsearchConfiguration getOrFail() throws IOException { + ElasticsearchGlobalConfiguration cfg = ElasticsearchGlobalConfiguration.get(); + ElasticsearchConfiguration esCfg = cfg != null ? cfg.getElasticsearch() : null; + if (esCfg == null) { + throw new AbortException("External Logging ES configuration is not set"); + } + return esCfg; + } +} diff --git a/src/main/java/io/jenkins/plugins/extlogging/elasticsearch/ElasticsearchEventWriter.java b/src/main/java/io/jenkins/plugins/extlogging/elasticsearch/ElasticsearchEventWriter.java new file mode 100644 index 0000000..8ae7f18 --- /dev/null +++ b/src/main/java/io/jenkins/plugins/extlogging/elasticsearch/ElasticsearchEventWriter.java @@ -0,0 +1,95 @@ + +package io.jenkins.plugins.extlogging.elasticsearch; + +import io.jenkins.plugins.extlogging.api.Event; +import io.jenkins.plugins.extlogging.api.ExternalLoggingEventWriter; + +import io.jenkins.plugins.extlogging.elasticsearch.util.ElasticSearchDao; +import io.jenkins.plugins.extlogging.elasticsearch.util.JSONConsoleNotes; +import net.sf.json.JSONObject; +import org.apache.commons.lang.exception.ExceptionUtils; +import org.apache.commons.lang.time.FastDateFormat; + +import javax.annotation.CheckForNull; +import javax.annotation.Nonnull; +import java.io.IOException; +import java.io.Serializable; +import java.util.Calendar; +import java.util.Map; +import java.util.logging.Level; +import java.util.logging.Logger; + +public class ElasticsearchEventWriter extends ExternalLoggingEventWriter { + + private static final long serialVersionUID = 1L; + private static final Logger LOGGER = Logger.getLogger(ElasticsearchEventWriter.class.getName()); + private static final FastDateFormat MILLIS_FORMATTER = FastDateFormat.getInstance("yyyy-MM-dd'T'HH:mm:ss.SSSZ"); + + @CheckForNull + private final String prefix; + @Nonnull + private final ElasticSearchDao dao; + private boolean connectionBroken; + + public ElasticsearchEventWriter(@CheckForNull String prefix, + @Nonnull ElasticSearchDao dao) { + this.prefix = prefix; + this.dao = dao; + } + + @Override + public void writeMessage(String message) throws IOException { + super.writeMessage(prefix != null ? prefix + message : message); + } + + @Override + public void writeEvent(Event event) { + JSONObject payload = new JSONObject(); + JSONConsoleNotes.parseToJSON(event.getMessage(), payload); + // TODO: replace Dao implementation by an independent one + JSONObject data = new JSONObject(); + for (Map.Entry entry : event.getData().entrySet()) { + Serializable value = entry.getValue(); + data.accumulate(entry.getKey(), value != null ? value.toString() : null); + } + payload.put("data", data); + //TODO: Use Event timestamp everywhere? + payload.put("@buildTimestamp", MILLIS_FORMATTER.format(event.getTimestamp())); + payload.put("@timestamp", MILLIS_FORMATTER.format(Calendar.getInstance().getTime())); + payload.put("@version", 1); + + try { + dao.push(payload.toString()); + } catch (IOException e) { + String msg = "[logstash-plugin]: Failed to send log data to " + dao.getDescription() + ".\n" + + "[logstash-plugin]: No Further logs will be sent to " + dao.getDescription() + ".\n" + + ExceptionUtils.getStackTrace(e); + logErrorMessage(msg); + } + } + + /** + * @return True if errors have occurred during initialization or write. + */ + public boolean isConnectionBroken() { + return connectionBroken || dao == null; + } + + /** + * Write error message to errorStream and set connectionBroken to true. + */ + private void logErrorMessage(String msg) { + connectionBroken = true; + LOGGER.log(Level.WARNING, msg); + } + + @Override + public void flush() throws IOException { + // no caching, nothing to do here + } + + @Override + public void close() throws IOException { + // dao handles it + } +} diff --git a/src/main/java/io/jenkins/plugins/extlogging/elasticsearch/ElasticsearchGlobalConfiguration.java b/src/main/java/io/jenkins/plugins/extlogging/elasticsearch/ElasticsearchGlobalConfiguration.java new file mode 100644 index 0000000..0443863 --- /dev/null +++ b/src/main/java/io/jenkins/plugins/extlogging/elasticsearch/ElasticsearchGlobalConfiguration.java @@ -0,0 +1,95 @@ +package io.jenkins.plugins.extlogging.elasticsearch; + +import com.cloudbees.plugins.credentials.common.UsernamePasswordCredentials; +import hudson.AbortException; +import hudson.Extension; +import io.jenkins.plugins.extlogging.elasticsearch.util.ElasticSearchDao; +import jenkins.model.GlobalConfiguration; +import org.jenkinsci.Symbol; +import javax.annotation.CheckForNull; +import javax.annotation.Nonnull; +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; + +/** + * @author Oleg Nenashev + * @since TODO + */ +@Extension +@Symbol("extLoggingES") +public class ElasticsearchGlobalConfiguration extends GlobalConfiguration { + + @CheckForNull + private ElasticsearchConfiguration elasticsearch; + + @CheckForNull + private String key; + + public ElasticsearchGlobalConfiguration() { + load(); + } + + @CheckForNull + public static ElasticsearchGlobalConfiguration get() { + return GlobalConfiguration.all().getInstance(ElasticsearchGlobalConfiguration.class); + } + + @Nonnull + public static ElasticsearchGlobalConfiguration getInstance() throws IOException { + ElasticsearchGlobalConfiguration cfg = get(); + if (cfg == null) { + throw new IOException("Elasticsearch Ext Logging Global Configuration is not set"); + } + return cfg; + } + + @CheckForNull + public ElasticsearchConfiguration getElasticsearch() { + return elasticsearch; + } + + public void setElasticsearch(ElasticsearchConfiguration configuration) { + this.elasticsearch = configuration; + save(); + } + + public void setKey(@CheckForNull String key) { + this.key = key; + save(); + } + + @CheckForNull + public String getKey() { + return key; + } + + public ElasticSearchDao toDao() throws IOException { + if (elasticsearch == null) { + throw new AbortException("Elasticsearch is not configured"); + } + if (key == null) { + throw new AbortException("Elasticsearch: Key is not configured"); + } + + URI esURI = null; + try { + esURI = new URI(elasticsearch.getUri() + key); + } catch (URISyntaxException e) { + throw new IOException("Malformed Elasticsearc URI: " + elasticsearch.getUri(), e); + } + + String username = null; + String password = null; + if (elasticsearch.getCredentialsId() != null) { + UsernamePasswordCredentials creds = elasticsearch.getCredentialsOrFail(); + username = creds.getUsername(); + password = creds.getPassword().getPlainText(); + } + + return new ElasticSearchDao(esURI, username, password); + } + + //TODO: Configuration UI & Co + +} diff --git a/src/main/java/io/jenkins/plugins/extlogging/elasticsearch/ElasticsearchLogBrowser.java b/src/main/java/io/jenkins/plugins/extlogging/elasticsearch/ElasticsearchLogBrowser.java new file mode 100644 index 0000000..ba3f0db --- /dev/null +++ b/src/main/java/io/jenkins/plugins/extlogging/elasticsearch/ElasticsearchLogBrowser.java @@ -0,0 +1,46 @@ +package io.jenkins.plugins.extlogging.elasticsearch; + +import hudson.console.AnnotatedLargeText; +import io.jenkins.plugins.extlogging.elasticsearch.util.ElasticSearchDao; +import jenkins.model.logging.LogBrowser; +import jenkins.model.logging.Loggable; +import jenkins.model.logging.impl.BrokenAnnotatedLargeText; + +import javax.annotation.CheckForNull; + +/** + * Log browser for Elasticsearch. + * @author Oleg Nenashev + * @since TODO + */ +public class ElasticsearchLogBrowser extends LogBrowser { + + public ElasticsearchLogBrowser(Loggable loggable) { + super(loggable); + } + + //TODO: Cache values instead of refreshing them each time + @Override + public AnnotatedLargeText overallLog() { + ElasticSearchDao dao; + try { + dao = ElasticsearchGlobalConfiguration.getInstance().toDao(); + } catch (Exception ex) { + return new BrokenAnnotatedLargeText(ex); + } + + return new ElasticsearchLogLargeTextProvider(dao, getOwner(), null).getLogText(); + } + + @Override + public AnnotatedLargeText stepLog(@CheckForNull String stepId, boolean b) { + ElasticSearchDao dao; + try { + dao = ElasticsearchGlobalConfiguration.getInstance().toDao(); + } catch (Exception ex) { + return new BrokenAnnotatedLargeText(ex); + } + + return new ElasticsearchLogLargeTextProvider(dao, getOwner(), stepId).getLogText(); + } +} diff --git a/src/main/java/io/jenkins/plugins/extlogging/elasticsearch/ElasticsearchLogBrowserFactory.java b/src/main/java/io/jenkins/plugins/extlogging/elasticsearch/ElasticsearchLogBrowserFactory.java new file mode 100644 index 0000000..bafe4b6 --- /dev/null +++ b/src/main/java/io/jenkins/plugins/extlogging/elasticsearch/ElasticsearchLogBrowserFactory.java @@ -0,0 +1,40 @@ +package io.jenkins.plugins.extlogging.elasticsearch; + +import hudson.Extension; +import hudson.model.Run; +import io.jenkins.plugins.extlogging.api.ExternalLogBrowserFactory; +import io.jenkins.plugins.extlogging.api.ExternalLogBrowserFactoryDescriptor; +import jenkins.model.logging.LogBrowser; +import jenkins.model.logging.Loggable; +import org.jenkinsci.Symbol; +import org.kohsuke.stapler.DataBoundConstructor; + +import javax.annotation.CheckForNull; + +/** + * Produces {@link ElasticsearchLogBrowser}s. + * @author Oleg Nenashev + * @since TODO + */ +public class ElasticsearchLogBrowserFactory extends ExternalLogBrowserFactory { + + @DataBoundConstructor + public ElasticsearchLogBrowserFactory() { + + } + + @CheckForNull + @Override + public LogBrowser create(Loggable loggable) { + if (loggable instanceof Run) { + return new ElasticsearchLogBrowser((Run) loggable); + } + return null; + } + + @Extension + @Symbol("logstashElasticsearch") + public static class DescriptorImpl extends ExternalLogBrowserFactoryDescriptor { + + } +} diff --git a/src/main/java/io/jenkins/plugins/extlogging/elasticsearch/ElasticsearchLogLargeTextProvider.java b/src/main/java/io/jenkins/plugins/extlogging/elasticsearch/ElasticsearchLogLargeTextProvider.java new file mode 100644 index 0000000..1ddc1cb --- /dev/null +++ b/src/main/java/io/jenkins/plugins/extlogging/elasticsearch/ElasticsearchLogLargeTextProvider.java @@ -0,0 +1,211 @@ +package io.jenkins.plugins.extlogging.elasticsearch; + +import hudson.console.AnnotatedLargeText; +import hudson.model.Run; +import java.io.IOException; +import java.io.InputStream; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; +import javax.annotation.CheckForNull; +import javax.annotation.Nonnull; + +import io.jenkins.plugins.extlogging.api.util.UniqueIdHelper; +import io.jenkins.plugins.extlogging.elasticsearch.util.ElasticSearchDao; +import io.jenkins.plugins.extlogging.elasticsearch.util.JSONConsoleNotes; +import io.jenkins.plugins.extlogging.elasticsearch.util.HttpGetWithData; +import jenkins.model.logging.Loggable; +import net.sf.json.JSONArray; +import net.sf.json.JSONObject; +import org.apache.commons.io.IOUtils; +import org.apache.commons.jelly.XMLOutput; +import org.apache.commons.lang.StringUtils; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.entity.ContentType; +import org.apache.http.entity.StringEntity; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClientBuilder; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; +import org.kohsuke.stapler.framework.io.ByteBuffer; + +/** + * Displays Embedded log. + * @author Oleg Nenashev + */ +@Restricted(NoExternalUse.class) +public class ElasticsearchLogLargeTextProvider { + + @Nonnull + private ElasticSearchDao esDao; + + @Nonnull + private Loggable loggable; + + @CheckForNull + private String stepId; + + public ElasticsearchLogLargeTextProvider(@Nonnull ElasticSearchDao dao, @Nonnull Loggable loggable) { + this(dao, loggable, null); + } + + public ElasticsearchLogLargeTextProvider(@Nonnull ElasticSearchDao dao, @Nonnull Loggable loggable, @CheckForNull String stepId) { + this.esDao = dao; + this.loggable = loggable; + this.stepId = stepId; + } + + private transient HttpClientBuilder clientBuilder; + + /** + * Used from index.jelly to write annotated log to the given + * output. + * @param offset offset of the log + * @param out destination output + */ + public void writeLogTo(long offset, @Nonnull XMLOutput out) throws IOException { + try { + getLogText().writeHtmlTo(offset, out.asWriter()); + } catch (IOException e) { + // try to fall back to the old getLogInputStream() + // mainly to support .gz compressed files + // In this case, console annotation handling will be turned off. + InputStream input = readLogToBuffer(offset).newInputStream(); + try { + IOUtils.copy(input, out.asWriter()); + } finally { + IOUtils.closeQuietly(input); + } + } + } + + /** + * Used to URL-bind {@link AnnotatedLargeText}. + * @return A {@link Run} log with annotations + */ + public @Nonnull AnnotatedLargeText getLogText() { + ByteBuffer buf; + try { + buf = readLogToBuffer(0); + } catch (IOException ex) { + buf = new ByteBuffer(); + //TODO: return new BrokenAnnotatedLargeText(ex); + } + return new UncompressedAnnotatedLargeText(buf, StandardCharsets.UTF_8, loggable.isLoggingFinished(), this); + } + + /** + * Returns an input stream that reads from the log file. + * @throws IOException Operation error + */ + @Nonnull + public ByteBuffer readLogToBuffer(long initialOffset) throws IOException { + ByteBuffer buffer = new ByteBuffer(); + Writer wr = new Writer() { + @Override + public void write(char[] cbuf, int off, int len) throws IOException { + byte[] bytes = new String(cbuf).getBytes("UTF-8"); + buffer.write(bytes, off, len); + } + + @Override + public void flush() throws IOException { + buffer.flush(); + } + + @Override + public void close() throws IOException { + buffer.close(); + } + }; + pullLogs(wr, esDao,0, Long.MAX_VALUE); + return buffer; + } + + //TODO: Move to External Logging API + private Map produceMatchers() { + Map eqMatchers = new HashMap<>(); + if (loggable instanceof Run) { + Run run = (Run)loggable; + eqMatchers.put("jobId", UniqueIdHelper.getOrCreateId(run.getParent())); + eqMatchers.put("buildNum", Integer.toString(run.getNumber())); + } + + if (stepId != null) { + eqMatchers.put("stepId", stepId); + } + return eqMatchers; + } + + private String getMatchQuery() { + //TODO: Can be optimized a lot + //TODO: Proper escaping to avoid query injection + Map matchers = produceMatchers(); + ArrayList conditions = new ArrayList<>(matchers.size()); + for (Map.Entry entry : matchers.entrySet()) { + String filter = String.format("{ \"match\": { \"data.%s\": \"%s\"}}", + entry.getKey(), entry.getValue()); + conditions.add(filter); + } + return StringUtils.join(conditions, ","); + } + + private void pullLogs(Writer writer, ElasticSearchDao dao, long sinceMs, long toMs) throws IOException { + + // Prepare query + //TODO: stored_fields in ES5 + String query = "{\n" + + " \"fields\": [\"message\",\"@timestamp\"], \n" + + " \"size\": 9999, \n" + // TODO use paging https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-from-size.html + " \"query\": { \n" + + " \"bool\": { \n" + + " \"must\": [ " + getMatchQuery() + " ]\n" + + " }\n" + + " }\n" + + "}"; + + + // Prepare request + final HttpGetWithData getRequest = new HttpGetWithData(dao.getUri() + "/_search"); + final StringEntity input = new StringEntity(query, ContentType.APPLICATION_JSON); + getRequest.setEntity(input); + + final String auth = dao.getAuth(); + if (auth != null) { + getRequest.addHeader("Authorization", "Basic " + auth); + } + + try(CloseableHttpClient httpClient = clientBuilder().build(); + CloseableHttpResponse response = httpClient.execute(getRequest)) { + + if (response.getStatusLine().getStatusCode() != 200) { + throw new IOException(HttpGetWithData.getErrorMessage(dao.getUri(), response)); + } + + // TODO: retrieve log entries + final String content; + try(InputStream i = response.getEntity().getContent()) { + content = IOUtils.toString(i); + } + + final JSONObject json = JSONObject.fromObject(content); + JSONArray jsonArray = json.getJSONObject("hits").getJSONArray("hits"); + for (int i=0; i passwordStrings; + + public LogstashOutputStreamWrapper(ElasticsearchEventWriter wr, List passwordStrings, String prefix) { + this.wr = wr; + this.passwordStrings = passwordStrings; + } + + public Object readResolve() { + return ExternalLoggingOutputStream.createOutputStream(wr, passwordStrings); + } + + @Override + public OutputStream toSerializableOutputStream() { + return ExternalLoggingOutputStream.createOutputStream(wr, passwordStrings); + } + } + +} \ No newline at end of file diff --git a/src/main/java/io/jenkins/plugins/extlogging/elasticsearch/UncompressedAnnotatedLargeText.java b/src/main/java/io/jenkins/plugins/extlogging/elasticsearch/UncompressedAnnotatedLargeText.java new file mode 100644 index 0000000..00b60f8 --- /dev/null +++ b/src/main/java/io/jenkins/plugins/extlogging/elasticsearch/UncompressedAnnotatedLargeText.java @@ -0,0 +1,99 @@ +package io.jenkins.plugins.extlogging.elasticsearch; + +import hudson.console.AnnotatedLargeText; +import hudson.console.ConsoleAnnotationOutputStream; +import hudson.console.ConsoleAnnotator; +import hudson.remoting.ObjectInputStreamEx; +import jenkins.model.Jenkins; +import jenkins.security.CryptoConfidentialKey; +import org.kohsuke.stapler.Stapler; +import org.kohsuke.stapler.StaplerRequest; +import org.kohsuke.stapler.framework.io.ByteBuffer; + +import javax.crypto.Cipher; +import javax.crypto.CipherInputStream; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.Writer; +import java.nio.charset.Charset; +import java.util.concurrent.TimeUnit; +import java.util.zip.GZIPInputStream; +//TODO: replace +import com.trilead.ssh2.crypto.Base64; + +import static java.lang.Math.abs; + +public class UncompressedAnnotatedLargeText extends AnnotatedLargeText { + + private T context; + private ByteBuffer memory; + + public UncompressedAnnotatedLargeText(ByteBuffer memory, Charset charset, boolean completed, T context) { + super(memory, charset, completed, context); + this.context = context; + this.memory = memory; + } + + @Override + public long writeHtmlTo(long start, Writer w) throws IOException { + ConsoleAnnotationOutputStream caw = new ConsoleAnnotationOutputStream( + w, createAnnotator(Stapler.getCurrentRequest()), context, charset); + long r = super.writeLogTo(start, caw); + caw.flush(); + long initial = memory.length(); + /* + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + Cipher sym = PASSING_ANNOTATOR.encrypt(); + ObjectOutputStream oos = new ObjectOutputStream(new GZIPOutputStream(new CipherOutputStream(baos, sym))); + oos.writeLong(System.currentTimeMillis()); // send timestamp to prevent a replay attack + oos.writeObject(caw.getConsoleAnnotator()); + oos.close(); + StaplerResponse rsp = Stapler.getCurrentResponse(); + if (rsp != null) { + rsp.setHeader("X-ConsoleAnnotator", new String(Base64.encode(baos.toByteArray()))); + } + return r; + */ + + /* + try { + memory.writeTo(caw); + } finally { + caw.flush(); + caw.close(); + }*/ + return initial - memory.length(); + } + + /** + * Used for sending the state of ConsoleAnnotator to the client, because we are deserializing this object later. + */ + private static final CryptoConfidentialKey PASSING_ANNOTATOR = new CryptoConfidentialKey(AnnotatedLargeText.class,"consoleAnnotator"); + + + private ConsoleAnnotator createAnnotator(StaplerRequest req) throws IOException { + try { + String base64 = req!=null ? req.getHeader("X-ConsoleAnnotator") : null; + if (base64!=null) { + Cipher sym = PASSING_ANNOTATOR.decrypt(); + + ObjectInputStream ois = new ObjectInputStreamEx(new GZIPInputStream( + new CipherInputStream(new ByteArrayInputStream(Base64.decode(base64.toCharArray())),sym)), + Jenkins.getInstance().pluginManager.uberClassLoader); + try { + long timestamp = ois.readLong(); + if (TimeUnit.HOURS.toMillis(1) > abs(System.currentTimeMillis()-timestamp)) + // don't deserialize something too old to prevent a replay attack + return (ConsoleAnnotator)ois.readObject(); + } finally { + ois.close(); + } + } + } catch (ClassNotFoundException e) { + throw new IOException(e); + } + // start from scratch + return ConsoleAnnotator.initial(context==null ? null : context.getClass()); + } +} \ No newline at end of file diff --git a/src/main/java/io/jenkins/plugins/extlogging/elasticsearch/util/ElasticSearchDao.java b/src/main/java/io/jenkins/plugins/extlogging/elasticsearch/util/ElasticSearchDao.java new file mode 100644 index 0000000..ade34f1 --- /dev/null +++ b/src/main/java/io/jenkins/plugins/extlogging/elasticsearch/util/ElasticSearchDao.java @@ -0,0 +1,182 @@ +/* + * The MIT License + * + * Copyright 2014-2018 Barnes and Noble College, Liam Newman, + * CloudBees Inc., and other Jenkins contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package io.jenkins.plugins.extlogging.elasticsearch.util; + +import com.google.common.collect.Range; +import org.apache.commons.codec.binary.Base64; +import org.apache.commons.lang.StringUtils; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.entity.ContentType; +import org.apache.http.entity.StringEntity; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClientBuilder; + +import javax.annotation.CheckForNull; +import javax.annotation.Nonnull; +import java.io.IOException; +import java.io.Serializable; +import java.net.MalformedURLException; +import java.net.URI; +import java.nio.charset.StandardCharsets; + +import static com.google.common.collect.Ranges.closedOpen; + + +/** + * Elastic Search Data Access Object. + * Originally the implementation was in the Elasticsearch plugin, + * but is has been copied here. + * The main difference is that this implementation is serializable. + * @author Liam Newman + * @author Oleg Nenashev + */ +public class ElasticSearchDao implements Serializable { + + private static final Range SUCCESS_CODES = closedOpen(200,300); + + private final URI uri; + @CheckForNull + private String username; + @CheckForNull + private String password; + @CheckForNull + private String mimeType; + + @CheckForNull + private transient HttpClientBuilder clientBuilder; + + @CheckForNull + private transient String auth; + + //primary constructor used by indexer factory + public ElasticSearchDao(URI uri, String username, String password) { + this(null, uri, username, password); + } + + // Factored for unit testing + public ElasticSearchDao(@CheckForNull HttpClientBuilder factory, + @Nonnull URI uri, + @CheckForNull String username, + @CheckForNull String password) { + this.uri = uri; + this.username = username; + this.password = password; + + try { + uri.toURL(); + } catch (MalformedURLException e) { + throw new IllegalArgumentException(e); + } + + clientBuilder = factory; + } + + @Nonnull + public HttpClientBuilder getClientBuilder() { + if (clientBuilder == null) { + clientBuilder = HttpClientBuilder.create(); + } + return clientBuilder; + } + + public URI getUri() { + return uri; + } + + public String getHost() { + return uri.getHost(); + } + + public String getScheme() { + return uri.getScheme(); + } + + public int getPort() { + return uri.getPort(); + } + + public String getUsername() { + return username; + } + + public String getPassword() { + return password; + } + + public String getKey() + { + return uri.getPath(); + } + + public String getMimeType() { + return this.mimeType; + } + + public void setMimeType(String mimeType) { + this.mimeType = mimeType; + } + + @CheckForNull + public String getAuth() { + if (auth == null && StringUtils.isNotBlank(username)) { + auth = Base64.encodeBase64String((username + ":" + StringUtils.defaultString(password)).getBytes(StandardCharsets.UTF_8)); + } + return auth; + } + + HttpPost getHttpPost(String data) { + HttpPost postRequest = new HttpPost(uri); + String mimeType = this.getMimeType(); + // char encoding is set to UTF_8 since this request posts a JSON string + StringEntity input = new StringEntity(data, StandardCharsets.UTF_8); + mimeType = (mimeType != null) ? mimeType : ContentType.APPLICATION_JSON.toString(); + input.setContentType(mimeType); + postRequest.setEntity(input); + if (auth != null) { + postRequest.addHeader("Authorization", "Basic " + auth); + } + return postRequest; + } + + public void push(String data) throws IOException { + + HttpPost post = getHttpPost(data); + try(CloseableHttpClient httpClient = getClientBuilder().build(); + CloseableHttpResponse response = httpClient.execute(post)) { + + if (!SUCCESS_CODES.contains(response.getStatusLine().getStatusCode())) { + throw new IOException(HttpGetWithData.getErrorMessage(uri, response)); + } + } + } + + + public String getDescription() + { + return uri.toString(); + } +} diff --git a/src/main/java/io/jenkins/plugins/extlogging/elasticsearch/util/HttpGetWithData.java b/src/main/java/io/jenkins/plugins/extlogging/elasticsearch/util/HttpGetWithData.java new file mode 100644 index 0000000..03e78d7 --- /dev/null +++ b/src/main/java/io/jenkins/plugins/extlogging/elasticsearch/util/HttpGetWithData.java @@ -0,0 +1,104 @@ +/* + * The MIT License + * + * Copyright 2014-2018 Barnes and Noble College, Liam Newman, + * CloudBees Inc., and other Jenkins contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package io.jenkins.plugins.extlogging.elasticsearch.util; + +import org.apache.commons.lang.exception.ExceptionUtils; +import org.apache.http.Header; +import org.apache.http.HttpEntity; +import org.apache.http.HttpEntityEnclosingRequest; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.protocol.HTTP; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.PrintStream; +import java.io.UnsupportedEncodingException; +import java.net.URI; +import java.nio.charset.StandardCharsets; + +/** + * Utility class for accessing Elasticsearch. + * Originally the implementation was in the Elasticsearch plugin, + * but is has been copied here. + * @author Liam Newman + * @author Oleg Nenashev + */ +@Restricted(NoExternalUse.class) +public class HttpGetWithData extends HttpGet implements HttpEntityEnclosingRequest { + private HttpEntity entity; + + public HttpGetWithData(String uri) { + super(uri); + } + + @Override + public HttpEntity getEntity() { + return this.entity; + } + + @Override + public void setEntity(final HttpEntity entity) { + this.entity = entity; + } + + @Override + public boolean expectContinue() { + final Header expect = getFirstHeader(HTTP.EXPECT_DIRECTIVE); + return expect != null && HTTP.EXPECT_CONTINUE.equalsIgnoreCase(expect.getValue()); + } + + public static String getErrorMessage(URI uri, CloseableHttpResponse response) { + ByteArrayOutputStream byteStream = null; + PrintStream stream = null; + try { + byteStream = new ByteArrayOutputStream(); + stream = new PrintStream(byteStream, true, StandardCharsets.UTF_8.name()); + + try { + stream.print("HTTP error code: "); + stream.println(response.getStatusLine().getStatusCode()); + stream.print("URI: "); + stream.println(uri.toString()); + stream.println("RESPONSE: " + response.toString()); + response.getEntity().writeTo(stream); + } catch (IOException e) { + stream.println(ExceptionUtils.getStackTrace(e)); + } + stream.flush(); + return byteStream.toString(StandardCharsets.UTF_8.name()); + } + catch (UnsupportedEncodingException e) + { + return ExceptionUtils.getStackTrace(e); + } finally { + if (stream != null) { + stream.close(); + } + } + } +} \ No newline at end of file diff --git a/src/main/java/io/jenkins/plugins/extlogging/elasticsearch/util/JSONConsoleNotes.java b/src/main/java/io/jenkins/plugins/extlogging/elasticsearch/util/JSONConsoleNotes.java new file mode 100644 index 0000000..3ff6eba --- /dev/null +++ b/src/main/java/io/jenkins/plugins/extlogging/elasticsearch/util/JSONConsoleNotes.java @@ -0,0 +1,104 @@ +package io.jenkins.plugins.extlogging.elasticsearch.util; + +import hudson.console.ConsoleNote; +import java.io.IOException; +import java.io.Writer; +import java.nio.charset.Charset; +import net.sf.json.JSONArray; +import net.sf.json.JSONObject; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.Beta; + +//TODO: Move to External Logging API +/** + * Utilities for extracting and reinserting {@link ConsoleNote}s. + * @author Jesse Glick + * @author Oleg Nenashev + */ +@Restricted(Beta.class) +public class JSONConsoleNotes { + + private static final String MESSAGE_KEY = "message"; + private static final String ANNOTATIONS_KEY = "annotations"; + private static final String POSITION_KEY = "position"; + private static final String NOTE_KEY = "note"; + + public static void parseToJSON(byte[] b, int len, Charset charset, JSONObject dest) { + assert len > 0 && len <= b.length; + int eol = len; + while (eol > 0) { + byte c = b[eol - 1]; + if (c == '\n' || c == '\r') { + eol--; + } else { + break; + } + } + String line = new String(b, 0, eol, charset); + parseToJSON(line, dest); + } + + public static void parseToJSON(String line, JSONObject dest) { + + // Would be more efficient to do searches at the byte[] level, but too much bother for now, + // especially since there is no standard library method to do offset searches like String has. + if (!line.contains(ConsoleNote.PREAMBLE_STR)) { + // Shortcut for the common case that we have no notes. + dest.put(MESSAGE_KEY, line); + } else { + StringBuilder buf = new StringBuilder(); + JSONArray annotations = new JSONArray(); + int pos = 0; + while (true) { + int preamble = line.indexOf(ConsoleNote.PREAMBLE_STR, pos); + if (preamble == -1) { + break; + } + int endOfPreamble = preamble + ConsoleNote.PREAMBLE_STR.length(); + int postamble = line.indexOf(ConsoleNote.POSTAMBLE_STR, endOfPreamble); + if (postamble == -1) { + // Malformed; stop here. + break; + } + buf.append(line, pos, preamble); + + JSONObject annotation = new JSONObject(); + annotation.put(POSITION_KEY, buf.length()); + annotation.put(NOTE_KEY, line.substring(endOfPreamble, postamble)); + annotations.add(annotation); + pos = postamble + ConsoleNote.POSTAMBLE_STR.length(); + } + + buf.append(line, pos, line.length()); // append tail + dest.put(MESSAGE_KEY, buf.toString()); + dest.put(ANNOTATIONS_KEY, annotations); + } + } + + public static void jsonToMessage(Writer w, JSONObject json) throws IOException { + //TODO: change for ES5 + // String message = json.getString(MESSAGE_KEY); + String message = json.getJSONArray(MESSAGE_KEY).getString(0); + JSONArray annotations = json.optJSONArray(ANNOTATIONS_KEY); + if (annotations == null) { + w.write(message); + } else { + int pos = 0; + for (Object o : annotations) { + JSONObject annotation = (JSONObject) o; + int position = annotation.getInt(POSITION_KEY); + String note = annotation.getString(NOTE_KEY); + w.write(message, pos, position - pos); + w.write(ConsoleNote.PREAMBLE_STR); + w.write(note); + w.write(ConsoleNote.POSTAMBLE_STR); + pos = position; + } + w.write(message, pos, message.length() - pos); + } + w.write('\n'); + } + + private JSONConsoleNotes() {} + +} \ No newline at end of file diff --git a/src/main/java/io/jenkins/plugins/extlogging/elasticsearch/util/RemoteLogstashOutputStream.java b/src/main/java/io/jenkins/plugins/extlogging/elasticsearch/util/RemoteLogstashOutputStream.java new file mode 100644 index 0000000..5ae2ffa --- /dev/null +++ b/src/main/java/io/jenkins/plugins/extlogging/elasticsearch/util/RemoteLogstashOutputStream.java @@ -0,0 +1,84 @@ +/* + * The MIT License + * + * Copyright 2014 K Jonathan Harker & Rusty Gerard + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package io.jenkins.plugins.extlogging.elasticsearch.util; + +import hudson.console.ConsoleNote; +import hudson.console.LineTransformationOutputStream; +import io.jenkins.plugins.extlogging.api.util.MaskSecretsOutputStream; +import io.jenkins.plugins.extlogging.elasticsearch.ElasticsearchEventWriter; + +import java.io.IOException; +import java.util.List; +import java.util.logging.Level; +import java.util.logging.Logger; + + +public class RemoteLogstashOutputStream extends LineTransformationOutputStream { + + final ElasticsearchEventWriter logstash; + final String prefix; + + private static final Logger LOGGER = Logger.getLogger(RemoteLogstashOutputStream.class.getName()); + + public RemoteLogstashOutputStream(ElasticsearchEventWriter logstash, String prefix) { + super(); + this.logstash = logstash; + this.prefix = prefix; + } + + + public MaskSecretsOutputStream maskPasswords(List passwordStrings) { + return new MaskSecretsOutputStream(this, passwordStrings); + } + + @Override + protected void eol(byte[] b, int len) throws IOException { + try { + this.flush(); + if (!logstash.isConnectionBroken()) { + String line = new String(b, 0, len).trim(); + line = ConsoleNote.removeNotes(line); + logstash.writeMessage(prefix + line); + } + } catch (Throwable ex) { + LOGGER.log(Level.SEVERE, "BOOM", ex); + } + } + + /** + * {@inheritDoc} + */ + @Override + public void flush() throws IOException { + super.flush(); + } + + /** + * {@inheritDoc} + */ + @Override + public void close() throws IOException { + super.close(); + } +} diff --git a/src/main/resources/index.jelly b/src/main/resources/index.jelly new file mode 100644 index 0000000..dd3e91d --- /dev/null +++ b/src/main/resources/index.jelly @@ -0,0 +1,5 @@ + + +
+ A Jenkins plugin to keep artifacts and Pipeline stashes in Amazon S3. +
diff --git a/src/main/resources/io/jenkins/plugins/extlogging/elasticsearch/ElasticsearchLogAction/index.jelly b/src/main/resources/io/jenkins/plugins/extlogging/elasticsearch/ElasticsearchLogAction/index.jelly new file mode 100644 index 0000000..469cce9 --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/extlogging/elasticsearch/ElasticsearchLogAction/index.jelly @@ -0,0 +1,43 @@ + + + + + + + + + + + + + ${%skipSome(offset/1024,"consoleFull")} + + + + + + + + + + + +
+            
+ +
+ + + + +
+            
+            ${it.writeLogTo(offset,output)}
+          
+
+ + + + + \ No newline at end of file diff --git a/src/test/java/io/jenkins/plugins/extlogging/elasticsearch/ElasticsearchContainer.java b/src/test/java/io/jenkins/plugins/extlogging/elasticsearch/ElasticsearchContainer.java new file mode 100644 index 0000000..89ba6c4 --- /dev/null +++ b/src/test/java/io/jenkins/plugins/extlogging/elasticsearch/ElasticsearchContainer.java @@ -0,0 +1,122 @@ +package io.jenkins.plugins.extlogging.elasticsearch; + +import java.net.MalformedURLException; +import java.net.URL; +import java.util.concurrent.TimeoutException; +import java.util.logging.Level; +import java.util.logging.Logger; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.jenkins.plugins.extlogging.api.impl.ExternalLoggingGlobalConfiguration; +import org.apache.http.NoHttpResponseException; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.jenkinsci.test.acceptance.docker.DockerContainer; +import org.jenkinsci.test.acceptance.docker.DockerFixture; +import org.jvnet.hudson.test.JenkinsRule; + +import javax.annotation.Nonnull; + +/** + * Elasticsearch test container. + * @author Oleg Nenashev + */ +@DockerFixture(id = "elasticsearch", ports = 9200) +public class ElasticsearchContainer extends DockerContainer { + + private static final Logger LOGGER = + Logger.getLogger(ElasticsearchContainer.class.getName()); + + @Nonnull + public URL getURL() { + try { + return new URL("http://" + ipBound(9200) + ":" + port(9200)); + } catch (MalformedURLException ex) { + throw new AssertionError(ex); + } + } + + public void configureJenkins(JenkinsRule j) throws AssertionError { + try { + ElasticsearchGlobalConfiguration es = ElasticsearchGlobalConfiguration.getInstance(); + es.setElasticsearch(new ElasticsearchConfiguration(getURL().toString())); + es.setKey("/logstash/logs"); + } catch (Exception ex) { + throw new AssertionError("Failed to configure Logstash Plugin using reflection", ex); + } + + ExternalLoggingGlobalConfiguration cfg = ExternalLoggingGlobalConfiguration.getInstance(); + cfg.setLogBrowser(new ElasticsearchLogBrowserFactory()); + cfg.setLoggingMethod(new ElasicsearchLoggingMethodFactory()); + + } + + public void waitForInit(int timeoutMs) throws AssertionError, Exception { + + long startTime = System.currentTimeMillis(); + ObjectMapper mapper = new ObjectMapper(); + + while (System.currentTimeMillis() < startTime + timeoutMs) { + try (CloseableHttpClient httpclient = HttpClients.createMinimal()) { + HttpGet httpGet = new HttpGet(getURL().toString()); + try (CloseableHttpResponse response = httpclient.execute(httpGet)) { + if (response.getStatusLine().getStatusCode() == 200) { + ElasticsearchInfo es = mapper.readValue(response.getEntity().getContent(), ElasticsearchInfo.class); + LOGGER.log(Level.FINE, "ES version: " + es.version.number); + return; + } + } catch (NoHttpResponseException ex) { + // Fine, keep trying + } catch (Exception ex) { + // keep trying + LOGGER.log(Level.WARNING, "Wrong response", ex); + } + } + Thread.sleep(1000); + } + + throw new TimeoutException("Elasticsearch connection timeout: " + timeoutMs + "ms"); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ElasticsearchInfo { + + @JsonProperty + public int status; + + @JsonProperty + public String name; + + @JsonProperty("cluster_name") + public String clusterName; + + @JsonProperty + public ElasticsearchVersion version; + + @JsonProperty + public String tagline; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ElasticsearchVersion { + + @JsonProperty + public String number; + + @JsonProperty("build_hash") + public String buildHash; + + @JsonProperty("build_timestamp") + public String buildTimestamp; + + @JsonProperty("build_snapshot") + public boolean buildSnapshot; + + @JsonProperty("lucene_version") + public String luceneVersion; + } +} \ No newline at end of file diff --git a/src/test/java/io/jenkins/plugins/extlogging/elasticsearch/PipelineSmokeTest.java b/src/test/java/io/jenkins/plugins/extlogging/elasticsearch/PipelineSmokeTest.java new file mode 100644 index 0000000..c100e14 --- /dev/null +++ b/src/test/java/io/jenkins/plugins/extlogging/elasticsearch/PipelineSmokeTest.java @@ -0,0 +1,78 @@ +package io.jenkins.plugins.extlogging.elasticsearch; + +import hudson.model.Run; + +import hudson.model.labels.LabelAtom; +import org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition; +import org.jenkinsci.plugins.workflow.job.WorkflowJob; +import org.jenkinsci.test.acceptance.docker.DockerRule; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.jvnet.hudson.test.JenkinsRule; + +/** + * @author Oleg Nenashev + * @since TODO + */ +public class PipelineSmokeTest { + + @Rule + public DockerRule esContainer = new DockerRule(ElasticsearchContainer.class); + private ElasticsearchContainer container; + + @Rule + public JenkinsRule j = new JenkinsRule(); + + @Before + public void setup() throws Exception { + container = esContainer.get(); + container.waitForInit(30000); + container.configureJenkins(j); + } + + @Test + public void spotcheck_Default() throws Exception { + WorkflowJob project = j.createProject(WorkflowJob.class); + project.setDefinition(new CpsFlowDefinition("echo 'Hello'", true)); + Run build = j.buildAndAssertSuccess(project); + // Eventual consistency + //TODO(oleg_nenashev): Probably we need terminator entries in logs + //to automate handling of such use-cases + Thread.sleep(10000); + j.assertLogContains("Hello", build); + } + + @Test + public void spotcheck_cycle() throws Exception { + WorkflowJob project = j.createProject(WorkflowJob.class); + project.setDefinition(new CpsFlowDefinition("" + + "for (int i = 0; i<10; i++) {\n" + + " sleep 1\n" + + " echo \"count: ${i}\"\n" + + "}", true)); + Run build = j.buildAndAssertSuccess(project); + // Eventual consistency + //TODO(oleg_nenashev): Probably we need terminator entries in logs + //to automate handling of such use-cases + Thread.sleep(1000); + j.assertLogContains("count: 9", build); + } + + @Test + public void spotcheck_Agent() throws Exception { + j.createOnlineSlave(new LabelAtom("foo")); + + WorkflowJob project = j.createProject(WorkflowJob.class); + project.setDefinition(new CpsFlowDefinition("node('foo') {" + + " sh 'whoami'" + + "}", true)); + Run build = j.buildAndAssertSuccess(project); + // Eventual consistency + //TODO(oleg_nenashev): Probably we need terminator entries in logs + //to automate handling of such use-cases + Thread.sleep(1000); + j.assertLogContains("whoami", build); + } + +} diff --git a/src/test/resources/io/jenkins/plugins/extlogging/elasticsearch/ElasticsearchContainer/Dockerfile b/src/test/resources/io/jenkins/plugins/extlogging/elasticsearch/ElasticsearchContainer/Dockerfile new file mode 100644 index 0000000..15a7eef --- /dev/null +++ b/src/test/resources/io/jenkins/plugins/extlogging/elasticsearch/ElasticsearchContainer/Dockerfile @@ -0,0 +1,4 @@ +##TODO: update to ES 5.0 +#FROM sebp/elk:5610 +FROM sebp/elk:es241_l240_k461 +#TODO: Add support of easy Data browsing for tests?